M2 audit: excise managed connectors and xAI media-gen tools
Managed connectors (grok.com MCP admin) removed root-and-branch: - The managed-MCP fetch/injection pipeline is gone, including the whole kigi-shell-session-support crate (managed-config fetch client, gateway tool catalog + dispatch, header injection, refresh task), reactive managed re-auth, mcp_doctor's grok.com-source discovery, and the [managed_mcps] config surface. - TUI: the 'Managed by grok.com' section, connectors URL/deep-link, Action::OpenManagedConnectors, and session_team_id are gone. Local MCP management (list/toggle/add/remove/auth/tools) is fully intact. - Kept as LOCAL policy: managed-settings.json MCP allow/deny enforcement, the multi-source local MCP merge, folder-trust gating. PluginOrigin Project/User labels kept (they tag locally discovered plugin dirs). imagine/media-gen tools (xAI image/video generation) removed: - image_gen, image_edit, video_gen, image_to_video, reference_to_video implementations, registrations, ToolKind/ToolInput/Output variants (serde-safe), config plumbing end to end, ZDR video machinery, /imagine + /imagine-video commands and guidance text, the bundled imagine skill (added to legacy cleanup so user installs delete it), and the media-gen render path. - Kept: image INPUT (paste/attach, [Image #N] meta, pdf/image fetch, clipboard wrap), generic media-ref rendering, and the generic tool 401-retry machinery (tests renamed, assertions unweakened). - deploy_app stays: it is a permanently-disabled local stub deploying nowhere. 121 files changed, 8 deleted. Gates: workspace check/clippy 0/0, fmt, deny ok; suites green (tools 2554, shell 4862, tui 6608, workspace 1042). Remaining grok.com strings live only in the auth-method ids and changelog archives (§9/M3 sweep).
This commit is contained in:
@@ -321,26 +321,6 @@ Open the extensions modal on the Skills tab to view installed skills.
|
||||
|
||||
---
|
||||
|
||||
## Media Generation
|
||||
|
||||
### `/imagine <description>`
|
||||
|
||||
Generate an image from a text description.
|
||||
|
||||
```
|
||||
/imagine a golden sunset over a calm ocean with silhouetted palm trees
|
||||
```
|
||||
|
||||
### `/imagine-video <description>`
|
||||
|
||||
Generate a video from an image or text description. Plans shots, generates source images, and animates them with `image_to_video`.
|
||||
|
||||
```
|
||||
/imagine-video a cat playing piano in a jazz club
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scheduling
|
||||
|
||||
### `/loop [interval] <prompt>`
|
||||
|
||||
@@ -1897,13 +1897,6 @@ fn tool_call_to_block(tc: &acp::ToolCall, session_cwd: Option<&Path>) -> RenderB
|
||||
}
|
||||
RenderBlock::ToolCall(ToolCallBlock::UseTool(block))
|
||||
}
|
||||
_ if matches!(
|
||||
extract_raw_field(tc, "variant").as_deref(),
|
||||
Some("ImageGen") | Some("ImageToVideo") | Some("ReferenceToVideo") | Some("ImageEdit")
|
||||
) =>
|
||||
{
|
||||
media_gen_block(tc, success)
|
||||
}
|
||||
_ if tc.title.starts_with("Memory search:") => {
|
||||
let query = tc
|
||||
.title
|
||||
@@ -2007,45 +2000,6 @@ fn tool_call_title(tc: &acp::ToolCall) -> Cow<'_, str> {
|
||||
Cow::Borrowed(&tc.title)
|
||||
}
|
||||
}
|
||||
/// Build the media block from the typed `raw_output` path.
|
||||
fn media_gen_block(tc: &acp::ToolCall, success: bool) -> RenderBlock {
|
||||
let mut block = OtherToolCallBlock::new(tool_call_title(tc), String::new());
|
||||
if !success {
|
||||
let err = content_text(tc);
|
||||
block.error = Some(if err.is_empty() { "Failed".into() } else { err });
|
||||
} else if let Some((path, is_video)) = media_gen_ref(tc) {
|
||||
block = block.with_media_ref(path, is_video);
|
||||
} else if let Some(text) = media_gen_text(tc) {
|
||||
block.set_output_text(text);
|
||||
}
|
||||
RenderBlock::ToolCall(ToolCallBlock::Other(block))
|
||||
}
|
||||
/// Plain-text body of a media-variant tool that returned `ToolOutput::Text`
|
||||
/// rather than a media file (the free / X Basic SuperGrok-upsell short-circuit).
|
||||
/// `None` for real media outputs — including ZDR upload-only results — so their
|
||||
/// typed rendering is untouched.
|
||||
fn media_gen_text(tc: &acp::ToolCall) -> Option<String> {
|
||||
match serde_json::from_value::<ToolOutput>(tc.raw_output.clone()?).ok()? {
|
||||
ToolOutput::Text(t) => (!t.text.is_empty()).then_some(t.text),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Local `(path, is_video)` from typed `raw_output`.
|
||||
///
|
||||
/// Returns `None` when `raw_output` is missing/unparseable, not a media
|
||||
/// variant, or has no openable local file (ZDR `uploaded_url` / empty path).
|
||||
fn media_gen_ref(tc: &acp::ToolCall) -> Option<(std::path::PathBuf, bool)> {
|
||||
let (media, is_video) =
|
||||
match serde_json::from_value::<ToolOutput>(tc.raw_output.clone()?).ok()? {
|
||||
ToolOutput::ImageGen(m) | ToolOutput::ImageEdit(m) => (m, false),
|
||||
ToolOutput::ImageToVideo(m) | ToolOutput::ReferenceToVideo(m) => (m, true),
|
||||
_ => return None,
|
||||
};
|
||||
if media.uploaded_url.is_some() || media.path.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((media.path, is_video))
|
||||
}
|
||||
/// Extract text content from a ContentBlock.
|
||||
fn extract_text_from_content(content: &acp::ContentBlock) -> String {
|
||||
match content {
|
||||
@@ -5536,7 +5490,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn build_and_parse_tools_meta_round_trip() {
|
||||
let names = vec!["scheduler_create".to_string(), "image_gen".to_string()];
|
||||
let names = vec!["scheduler_create".to_string(), "web_search".to_string()];
|
||||
let wire = serde_json::json!({ "tools" : names });
|
||||
assert_eq!(parse_tools_meta(wire.as_object()), Some(names));
|
||||
}
|
||||
@@ -6421,112 +6375,4 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Every video ToolInput variant must route through `media_gen_block` so
|
||||
/// `[Open Video]` uses the typed `MediaGenOutput.path` (not a regex scrape
|
||||
/// of the JSON prompt text — fragile on Windows with %-encoded session dirs).
|
||||
#[test]
|
||||
fn video_tool_variants_use_typed_path_not_generic_scrape() {
|
||||
use crate::scrollback::block::BlockContent;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let video_path = dir.path().join("1.mp4");
|
||||
std::fs::write(&video_path, b"fake-mp4").unwrap();
|
||||
let cases: &[(&str, ToolOutput)] = &[
|
||||
(
|
||||
"ImageToVideo",
|
||||
ToolOutput::ImageToVideo(kigi_tools::types::output::MediaGenOutput::new(
|
||||
video_path.clone(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"ReferenceToVideo",
|
||||
ToolOutput::ReferenceToVideo(kigi_tools::types::output::MediaGenOutput::new(
|
||||
video_path.clone(),
|
||||
)),
|
||||
),
|
||||
];
|
||||
for (variant, output) in cases {
|
||||
let tc = acp::ToolCall::new(
|
||||
acp::ToolCallId::new(Arc::from(format!("media-{variant}"))),
|
||||
variant.to_string(),
|
||||
)
|
||||
.kind(acp::ToolKind::Other)
|
||||
.status(acp::ToolCallStatus::Completed)
|
||||
.content(vec![])
|
||||
.raw_input(Some(serde_json::json!({ "variant" : variant })))
|
||||
.raw_output(serde_json::to_value(output).ok())
|
||||
.locations(vec![]);
|
||||
let block = tool_call_to_block(&tc, None);
|
||||
let open_path = block
|
||||
.inline_open_button()
|
||||
.map(|(p, is_video)| {
|
||||
assert!(is_video, "{variant}: expected video open button");
|
||||
p
|
||||
})
|
||||
.or_else(|| block.video_references().first().map(|r| r.path.clone()))
|
||||
.unwrap_or_else(|| panic!("{variant}: missing media ref / open button"));
|
||||
assert_eq!(
|
||||
open_path, video_path,
|
||||
"{variant}: open path must be the typed MediaGenOutput.path"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn media_gen_ref_skips_uploaded_only_video() {
|
||||
let output = ToolOutput::ImageToVideo(kigi_tools::types::output::MediaGenOutput::uploaded(
|
||||
"https://bucket.example/videos/x.mp4".into(),
|
||||
));
|
||||
let tc = acp::ToolCall::new(
|
||||
acp::ToolCallId::new(Arc::from("zdr-upload")),
|
||||
"image_to_video",
|
||||
)
|
||||
.kind(acp::ToolKind::Other)
|
||||
.status(acp::ToolCallStatus::Completed)
|
||||
.content(vec![])
|
||||
.raw_input(Some(serde_json::json!({ "variant" : "ImageToVideo" })))
|
||||
.raw_output(serde_json::to_value(output).ok())
|
||||
.locations(vec![]);
|
||||
assert!(
|
||||
media_gen_ref(&tc).is_none(),
|
||||
"uploaded_url-only media must not claim a local open path"
|
||||
);
|
||||
}
|
||||
/// A tier-restricted (free / X Basic) imagine call short-circuits with the
|
||||
/// SuperGrok upsell as `ToolOutput::Text` on a `Completed` status. The media
|
||||
/// renderer has no file to open, so it must surface the upsell text in the
|
||||
/// card body (not a bare title) and must NOT mark the card as an error.
|
||||
#[test]
|
||||
fn tier_restricted_media_shows_upsell_text_not_error() {
|
||||
let upsell = "Image generation is a SuperGrok feature. Upgrade at \
|
||||
https://grok.com/supergrok?referrer=grok-build";
|
||||
let output = ToolOutput::Text(kigi_tools::types::output::TextOutput::from(upsell));
|
||||
let tc = acp::ToolCall::new(
|
||||
acp::ToolCallId::new(Arc::from("tier-restricted-img")),
|
||||
"image_gen",
|
||||
)
|
||||
.kind(acp::ToolKind::Other)
|
||||
.status(acp::ToolCallStatus::Completed)
|
||||
.content(vec![acp::ToolCallContent::Content(acp::Content::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new(upsell)),
|
||||
))])
|
||||
.raw_input(Some(serde_json::json!({ "variant" : "ImageGen" })))
|
||||
.raw_output(serde_json::to_value(output).ok())
|
||||
.locations(vec![]);
|
||||
let RenderBlock::ToolCall(ToolCallBlock::Other(block)) = tool_call_to_block(&tc, None)
|
||||
else {
|
||||
panic!("expected an Other tool-call block");
|
||||
};
|
||||
assert!(
|
||||
block.is_success(),
|
||||
"the upsell is a successful result, not an error"
|
||||
);
|
||||
assert!(
|
||||
block
|
||||
.output
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("SuperGrok"),
|
||||
"upsell text must be shown in the card body, got: {:?}",
|
||||
block.output
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +50,7 @@
|
||||
tools: Vec::new(),
|
||||
enabled: true,
|
||||
source: "local".into(),
|
||||
wire_source: crate::views::mcps_modal::McpWireSource::Local,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
},
|
||||
]));
|
||||
}
|
||||
@@ -520,9 +518,7 @@
|
||||
tools: Vec::new(),
|
||||
enabled: true,
|
||||
source: "local".into(),
|
||||
wire_source: crate::views::mcps_modal::McpWireSource::Local,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -1818,7 +1818,7 @@ pub(super) fn make_mcps_modal_with_servers(
|
||||
state
|
||||
}
|
||||
pub(super) fn seed_owner_agent_with_open_modal(app: &mut AppView) {
|
||||
use crate::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo, McpWireSource};
|
||||
use crate::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo};
|
||||
let owner = app.agents.get_mut(&AgentId(0)).expect("owner present");
|
||||
owner.extensions_modal = Some(
|
||||
make_mcps_modal_with_servers(
|
||||
@@ -1826,8 +1826,7 @@ pub(super) fn seed_owner_agent_with_open_modal(app: &mut AppView) {
|
||||
McpServerInfo { name : "alpha".into(), display_name : None, status :
|
||||
McpServerDisplayStatus::Initializing, tool_count : 0, auth_required :
|
||||
false, tools : Vec::new(), enabled : true, source : "local".into(),
|
||||
wire_source : McpWireSource::Local, plugin_name : None,
|
||||
is_managed_gateway : false, }
|
||||
plugin_name : None, }
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -58,8 +58,6 @@ pub enum Action {
|
||||
ExitSessionConfirmed,
|
||||
/// Open an arbitrary URL in the system browser (with scheme validation).
|
||||
OpenUrl(String),
|
||||
/// Open grok.com managed connectors, appending session teamId when set.
|
||||
OpenManagedConnectors,
|
||||
/// Cycle to the next visible link (or highlight the first if none selected).
|
||||
OpenNextLink,
|
||||
/// Cycle to the previous visible link.
|
||||
|
||||
@@ -100,7 +100,7 @@ impl QueuedPrompt {
|
||||
/// `true` for plain rows (no `wire_blocks`) and for raw skill slash rows
|
||||
/// (`/find-session args` — a single Text block equal to `text`, expanded
|
||||
/// shell-side at delivery), so interjecting `text` loses nothing. `false`
|
||||
/// when the payload was expanded client-side (`/imagine`, `/loop`):
|
||||
/// when the payload was expanded client-side (`/loop`):
|
||||
/// interjecting those by `text` would drop the expansion, and by payload
|
||||
/// would render the raw instruction.
|
||||
pub fn wire_matches_display(&self) -> bool {
|
||||
@@ -1272,7 +1272,7 @@ mod tests {
|
||||
}
|
||||
/// `wire_matches_display` splits interjectable rows (no payload, or a raw
|
||||
/// skill slash payload equal to the display text) from client-expanded
|
||||
/// payloads (`/imagine`, `/loop`) that must run as their own turn.
|
||||
/// payloads (`/loop`) that must run as their own turn.
|
||||
#[test]
|
||||
fn wire_matches_display_classifies_payload_shapes() {
|
||||
let text_block = |t: &str| acp::ContentBlock::Text(acp::TextContent::new(t.to_string()));
|
||||
@@ -1285,7 +1285,7 @@ mod tests {
|
||||
assert!(raw_skill.wire_matches_display(), "raw slash payload");
|
||||
let expanded = QueuedPrompt {
|
||||
wire_blocks: Some(vec![text_block("<skill>body</skill>")]),
|
||||
..QueuedPrompt::plain(3, "/imagine cat", QueueEntryKind::Prompt)
|
||||
..QueuedPrompt::plain(3, "/loop cat", QueueEntryKind::Prompt)
|
||||
};
|
||||
assert!(!expanded.wire_matches_display(), "expanded payload");
|
||||
let multi_block = QueuedPrompt {
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::test_fixtures;
|
||||
use crate::app::actions::Action;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::views::file_search::line_viewer::LineViewerState;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
impl AgentView {
|
||||
// -- Agents modal input handling --
|
||||
@@ -227,19 +227,6 @@ impl AgentView {
|
||||
return self.handle_modal_input_key(key);
|
||||
}
|
||||
|
||||
// Ctrl+O opens grok.com connectors on the MCP servers tab.
|
||||
if key.code == KeyCode::Char('o')
|
||||
&& key.modifiers == KeyModifiers::CONTROL
|
||||
&& self.extensions_modal.as_ref().is_some_and(|s| {
|
||||
s.active_tab == crate::views::extensions_modal::ExtensionsTab::McpServers
|
||||
&& !s.picker_state.search_active
|
||||
})
|
||||
{
|
||||
return self.execute_modal_button_action(
|
||||
crate::views::extensions_modal::ButtonAction::OpenManagedConnectors,
|
||||
);
|
||||
}
|
||||
|
||||
// Route chrome keys through ModalWindow first (mirrors the mouse path).
|
||||
// Handles Esc -> CloseRequested and h/l (or L/R when not tabs-focused)
|
||||
// -> fold outcomes when FoldInfo provided.
|
||||
@@ -768,22 +755,6 @@ impl AgentView {
|
||||
&config,
|
||||
);
|
||||
|
||||
// Open the connectors URL on mouse-down (parity with Ctrl+O). A section-row
|
||||
// click routes as Selected or NonSelectableClick, so intercept both here.
|
||||
let clicked_entry = match &outcome {
|
||||
crate::views::picker::PickerOutcome::Selected(i)
|
||||
| crate::views::picker::PickerOutcome::Expand(i)
|
||||
| crate::views::picker::PickerOutcome::NonSelectableClick(i) => Some(*i),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(idx) = clicked_entry
|
||||
&& self.extensions_modal_click_opens_connectors(idx, mouse.row)
|
||||
{
|
||||
return self.execute_modal_button_action(
|
||||
crate::views::extensions_modal::ButtonAction::OpenManagedConnectors,
|
||||
);
|
||||
}
|
||||
|
||||
// Hover states are managed by ModalWindow (close) and picker (filter).
|
||||
|
||||
match outcome {
|
||||
@@ -864,20 +835,6 @@ impl AgentView {
|
||||
state.picker_state.scroll_offset = None;
|
||||
}
|
||||
|
||||
/// Whether a click at `mouse_row` on entry `entry_idx` hit the connectors URL
|
||||
/// link band recorded at last paint (opens the URL instead of folding).
|
||||
fn extensions_modal_click_opens_connectors(&self, entry_idx: usize, mouse_row: u16) -> bool {
|
||||
self.extensions_modal.as_ref().is_some_and(|state| {
|
||||
// Parity with the Ctrl+O guard: don't open while the search bar has focus.
|
||||
!state.picker_state.search_active
|
||||
&& state
|
||||
.picker_state
|
||||
.link_band
|
||||
.as_ref()
|
||||
.is_some_and(|(idx, band)| *idx == entry_idx && band.contains(&mouse_row))
|
||||
})
|
||||
}
|
||||
|
||||
/// Non-selectable mask for the extensions modal picker (from last render).
|
||||
fn extensions_modal_non_selectable_mask(
|
||||
state: &crate::views::extensions_modal::ExtensionsModalState,
|
||||
@@ -1094,9 +1051,6 @@ impl AgentView {
|
||||
&& let Some(idx) = state.selected_data_index()
|
||||
&& let Some(server) = servers.get(idx)
|
||||
{
|
||||
if server.is_managed_gateway {
|
||||
return InputOutcome::Action(Action::OpenManagedConnectors);
|
||||
}
|
||||
// Drop repeats while an action is in flight on the same
|
||||
// row to avoid double-spawning the OAuth browser flow.
|
||||
let sel = state.picker_state.selected;
|
||||
@@ -1120,9 +1074,6 @@ impl AgentView {
|
||||
InputOutcome::Action(Action::ReloadSkills)
|
||||
}
|
||||
ButtonAction::RefreshMcpList => InputOutcome::Action(Action::RefreshMcpList),
|
||||
ButtonAction::OpenManagedConnectors => {
|
||||
InputOutcome::Action(Action::OpenManagedConnectors)
|
||||
}
|
||||
ButtonAction::ToggleSelectedMcpServer => {
|
||||
if let Some(ref mut state) = self.extensions_modal {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
@@ -1179,29 +1130,15 @@ impl AgentView {
|
||||
ButtonAction::RemoveSelectedMcpServer => {
|
||||
let resolved = self.extensions_modal.as_ref().and_then(|state| {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
use crate::views::mcps_modal::is_removable;
|
||||
let TabDataState::Loaded(ref servers) = state.mcps_data else {
|
||||
return None;
|
||||
};
|
||||
let idx = state.selected_data_index()?;
|
||||
let server = servers.get(idx)?;
|
||||
if is_removable(server) {
|
||||
Some(Ok(server.name.clone()))
|
||||
} else {
|
||||
Some(Err(server.name.clone()))
|
||||
}
|
||||
Some(server.name.clone())
|
||||
});
|
||||
match resolved {
|
||||
Some(Err(name)) => {
|
||||
if let Some(ref mut s) = self.extensions_modal {
|
||||
s.modal_message =
|
||||
Some(crate::views::extensions_modal::ModalMessage::Error(
|
||||
format!("Cannot remove managed server '{name}'"),
|
||||
));
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
Some(Ok(server_name)) => {
|
||||
Some(server_name) => {
|
||||
if let Some(ref mut s) = self.extensions_modal {
|
||||
s.pending_action = Some("removing...".into());
|
||||
s.pending_entry_index = Some(s.picker_state.selected);
|
||||
@@ -1629,148 +1566,3 @@ mod extensions_modal_search_key_tests {
|
||||
assert!(state.picker_state.search_active);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod connectors_url_click_tests {
|
||||
use super::AgentView;
|
||||
use crate::app::actions::Action;
|
||||
use crate::app::app_view::InputOutcome;
|
||||
use crate::views::extensions_modal::{
|
||||
ExtensionsModalState, ExtensionsTab, TabDataState, render_extensions_modal,
|
||||
};
|
||||
use crate::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo, McpWireSource};
|
||||
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
fn managed_server() -> McpServerInfo {
|
||||
McpServerInfo {
|
||||
name: "grok_com_linear".into(),
|
||||
display_name: None,
|
||||
status: McpServerDisplayStatus::Ready,
|
||||
tool_count: 0,
|
||||
auth_required: false,
|
||||
tools: vec![],
|
||||
enabled: true,
|
||||
source: "managed".into(),
|
||||
wire_source: McpWireSource::Managed,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Build an agent whose extensions modal shows an expanded Managed section,
|
||||
// then paint it so `hit_areas` + `link_band` reflect the real layout.
|
||||
fn rendered_agent() -> AgentView {
|
||||
let mut agent = super::test_fixtures::make_agent();
|
||||
let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers);
|
||||
state.mcps_data = TabDataState::Loaded(vec![managed_server()]);
|
||||
agent.extensions_modal = Some(state);
|
||||
let area = Rect::new(0, 0, 100, 40);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_extensions_modal(
|
||||
&mut buf,
|
||||
area,
|
||||
agent.extensions_modal.as_mut().unwrap(),
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
agent
|
||||
}
|
||||
|
||||
fn left_down(column: u16, row: u16) -> MouseEvent {
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column,
|
||||
row,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
// (column inside the Managed row, its recorded URL band) from the last paint.
|
||||
fn managed_url_hit(agent: &AgentView) -> (u16, std::ops::Range<u16>) {
|
||||
let state = agent.extensions_modal.as_ref().unwrap();
|
||||
let (entry_idx, band) = state
|
||||
.picker_state
|
||||
.link_band
|
||||
.clone()
|
||||
.expect("expanded Managed section records a connectors URL band");
|
||||
let hit = state.picker_state.hit_areas.as_ref().unwrap();
|
||||
let pos = hit
|
||||
.entry_indices
|
||||
.iter()
|
||||
.position(|&e| e == entry_idx)
|
||||
.unwrap();
|
||||
(hit.item_rects[pos].x + 2, band)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_down_on_url_row_opens_connectors() {
|
||||
let mut agent = rendered_agent();
|
||||
let (col, band) = managed_url_hit(&agent);
|
||||
let outcome = agent.handle_extensions_modal_mouse(&left_down(col, band.start));
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
InputOutcome::Action(Action::OpenManagedConnectors)
|
||||
));
|
||||
// The section stays expanded (opened, did not fold).
|
||||
assert!(
|
||||
!agent
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.mcps_collapsed_sections
|
||||
.contains("mcp-section:managed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_down_on_label_row_folds_not_opens() {
|
||||
let mut agent = rendered_agent();
|
||||
let (col, label_row) = {
|
||||
let state = agent.extensions_modal.as_ref().unwrap();
|
||||
let (entry_idx, _band) = state.picker_state.link_band.clone().unwrap();
|
||||
let hit = state.picker_state.hit_areas.as_ref().unwrap();
|
||||
let pos = hit
|
||||
.entry_indices
|
||||
.iter()
|
||||
.position(|&e| e == entry_idx)
|
||||
.unwrap();
|
||||
let rect = hit.item_rects[pos];
|
||||
(rect.x + 2, rect.y) // first row of the item rect is the fold-toggle label
|
||||
};
|
||||
let outcome = agent.handle_extensions_modal_mouse(&left_down(col, label_row));
|
||||
assert!(!matches!(
|
||||
outcome,
|
||||
InputOutcome::Action(Action::OpenManagedConnectors)
|
||||
));
|
||||
// Fold happened: the Managed section is now collapsed.
|
||||
assert!(
|
||||
agent
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.mcps_collapsed_sections
|
||||
.contains("mcp-section:managed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_down_on_url_row_while_searching_does_not_open() {
|
||||
// Parity with the Ctrl+O guard: opening is suppressed while search is active.
|
||||
let mut agent = rendered_agent();
|
||||
let (col, band) = managed_url_hit(&agent);
|
||||
agent
|
||||
.extensions_modal
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.picker_state
|
||||
.search_active = true;
|
||||
let outcome = agent.handle_extensions_modal_mouse(&left_down(col, band.start));
|
||||
assert!(!matches!(
|
||||
outcome,
|
||||
InputOutcome::Action(Action::OpenManagedConnectors)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@ impl AgentView {
|
||||
/// can't be resolved. Prompt-like rows may interject: plain prompts, plus
|
||||
/// raw skill slash rows (`/find-session args`) whose wire payload IS the
|
||||
/// display text — the shell expands those at the interjection drain. Rows
|
||||
/// with a client-expanded payload (`/imagine`, `/loop`) and non-prompt
|
||||
/// with a client-expanded payload (`/loop`) and non-prompt
|
||||
/// kinds stay queued: interjecting them would send the display text, not
|
||||
/// the payload.
|
||||
pub(in crate::app) fn queue_row_prompt_like(&self, id: u64) -> Option<bool> {
|
||||
@@ -1309,13 +1309,13 @@ mod queue_edit_routing_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A client-expanded row (`/imagine`-shaped: wire payload != display
|
||||
/// A client-expanded row (`/loop`-shaped: wire payload != display
|
||||
/// text) stays queued — interjecting it would send the display text,
|
||||
/// not the payload.
|
||||
#[test]
|
||||
fn force_interject_local_expanded_row_keeps_it_queued() {
|
||||
let mut agent =
|
||||
running_agent_with_local_skill("/imagine a cat", "<expanded imagine instructions>");
|
||||
running_agent_with_local_skill("/loop 5m check", "<expanded loop instructions>");
|
||||
let registry = non_vscode_registry();
|
||||
|
||||
let ids = agent.queue.entry_ids();
|
||||
|
||||
@@ -869,12 +869,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
Action::OpenManagedConnectors => {
|
||||
use crate::terminal::hyperlinks::SchemeFilter;
|
||||
let url = crate::views::mcps_modal::managed_connectors_url(None);
|
||||
crate::app::link_opener::open_url_if_safe(&url, SchemeFilter::Standard);
|
||||
vec![]
|
||||
}
|
||||
Action::OpenNextLink => {
|
||||
with_active_agent(app, |agent| agent.cycle_highlighted_link(true));
|
||||
vec![]
|
||||
|
||||
@@ -130,7 +130,7 @@ fn unknown_non_restricted_command_still_passes_through() {
|
||||
app.agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.set_restricted_commands(&["imagine".to_string()]);
|
||||
.set_restricted_commands(&["loop".to_string()]);
|
||||
|
||||
let effects = dispatch(Action::SendPrompt("/frobnicate arg".into()), &mut app);
|
||||
|
||||
|
||||
@@ -1212,7 +1212,6 @@ impl AgentView {
|
||||
badge: "",
|
||||
badge_color: None,
|
||||
collapsible: false,
|
||||
underline_last_desc: false,
|
||||
fields: &[],
|
||||
description_lines: &[],
|
||||
summary_lines: &[],
|
||||
@@ -1644,7 +1643,6 @@ impl AgentView {
|
||||
badge: "",
|
||||
badge_color: None,
|
||||
collapsible: false,
|
||||
underline_last_desc: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1716,7 +1714,6 @@ impl AgentView {
|
||||
badge: "",
|
||||
badge_color: None,
|
||||
collapsible: false,
|
||||
underline_last_desc: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -2008,7 +2005,6 @@ impl AgentView {
|
||||
badge: if has_snippet { "match" } else { "" },
|
||||
badge_color: Some(theme.accent_user),
|
||||
collapsible: true,
|
||||
underline_last_desc: false,
|
||||
}));
|
||||
non_sel_flags.push(false);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ impl BlockContent for OtherToolCallBlock {
|
||||
let muted_collapsed =
|
||||
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
|
||||
|
||||
// Inline media blocks (image_gen / video_gen): render the header and a
|
||||
// Inline media blocks: render the header and a
|
||||
// filepath line on every terminal.
|
||||
if let Some(media_path) = self.media_ref_path() {
|
||||
let header = self.collapsed_line(&theme, muted_collapsed, Some(ctx.content_width()));
|
||||
|
||||
@@ -2536,7 +2536,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn markdown_wrapped_session_media_path_fully_linkified() {
|
||||
// Regression: imagine-tool prose whose long session path soft-wraps
|
||||
// Regression: media-tool prose whose long session path soft-wraps
|
||||
// across rows. The whole path must be clickable (one overlay region
|
||||
// per row, all pointing at the full file:// URL) — not just the
|
||||
// leading path fragment on the first row.
|
||||
@@ -3285,7 +3285,7 @@ mod tests {
|
||||
|
||||
let mut entries = vec![
|
||||
ScrollbackEntry::new(RenderBlock::ToolCall(ToolCallBlock::Other(
|
||||
OtherToolCallBlock::new("image_gen", "saved image")
|
||||
OtherToolCallBlock::new("media_tool", "saved image")
|
||||
.with_media_ref(&image_path, false),
|
||||
))),
|
||||
ScrollbackEntry::new(RenderBlock::execute_with_output(
|
||||
@@ -4142,7 +4142,7 @@ mod tests {
|
||||
std::fs::write(&image_path, make_test_png(120, 120)).unwrap();
|
||||
|
||||
let entry = ScrollbackEntry::new(RenderBlock::ToolCall(ToolCallBlock::Other(
|
||||
OtherToolCallBlock::new("image_gen", "saved image").with_media_ref(&image_path, false),
|
||||
OtherToolCallBlock::new("media_tool", "saved image").with_media_ref(&image_path, false),
|
||||
)));
|
||||
let viewport = Rect::new(0, 0, 80, 30);
|
||||
let result = render_with_scratch(std::slice::from_ref(&entry), viewport, 0, None);
|
||||
|
||||
@@ -1980,7 +1980,7 @@ mod tests {
|
||||
|
||||
let mut state = ScrollbackState::new();
|
||||
state.push_block(RenderBlock::ToolCall(ToolCallBlock::Other(
|
||||
OtherToolCallBlock::new("image_to_video", "clip").with_media_ref(path.clone(), true),
|
||||
OtherToolCallBlock::new("media_tool", "clip").with_media_ref(path.clone(), true),
|
||||
)));
|
||||
|
||||
// Without ffmpeg the entry reserves only the compact banner.
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_tools::implementations::grok_build::{
|
||||
IMAGE_GEN_TOOL_NAME, IMAGINE_COMMAND_NAME, imagine_instruction, imagine_usage_message,
|
||||
};
|
||||
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
const REQUIRED_TOOLS: &[&str] = &[IMAGE_GEN_TOOL_NAME];
|
||||
|
||||
pub struct ImagineCommand;
|
||||
|
||||
impl SlashCommand for ImagineCommand {
|
||||
fn name(&self) -> &str {
|
||||
IMAGINE_COMMAND_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Generate an image from a text description"
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/imagine <description>"
|
||||
}
|
||||
|
||||
fn takes_args(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn args_required(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn arg_placeholder(&self) -> Option<&str> {
|
||||
Some("description of the image to generate")
|
||||
}
|
||||
|
||||
fn required_tools(&self) -> &[&str] {
|
||||
REQUIRED_TOOLS
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
let prompt = args.trim();
|
||||
if prompt.is_empty() {
|
||||
return CommandResult::Message(imagine_usage_message().to_string());
|
||||
}
|
||||
|
||||
CommandResult::InjectSkill {
|
||||
display_text: format!("/imagine {prompt}"),
|
||||
prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(
|
||||
imagine_instruction(prompt),
|
||||
))],
|
||||
display_as_skill: false,
|
||||
scheduled_task_preview: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn requires_image_gen_tool() {
|
||||
assert_eq!(ImagineCommand.required_tools(), &["image_gen"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prompt_returns_usage() {
|
||||
let models = crate::acp::model_state::ModelState::default();
|
||||
let mut ctx = super::super::tests::make_ctx(&models);
|
||||
let result = ImagineCommand.run(&mut ctx, "");
|
||||
assert!(matches!(result, CommandResult::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_prompt_returns_usage() {
|
||||
let models = crate::acp::model_state::ModelState::default();
|
||||
let mut ctx = super::super::tests::make_ctx(&models);
|
||||
let result = ImagineCommand.run(&mut ctx, " ");
|
||||
assert!(matches!(result, CommandResult::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_prompt_returns_inject_skill() {
|
||||
let models = crate::acp::model_state::ModelState::default();
|
||||
let mut ctx = super::super::tests::make_ctx(&models);
|
||||
let result = ImagineCommand.run(&mut ctx, "a golden sunset");
|
||||
match result {
|
||||
CommandResult::InjectSkill {
|
||||
display_text,
|
||||
prompt_blocks,
|
||||
display_as_skill,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(display_text, "/imagine a golden sunset");
|
||||
assert!(!display_as_skill);
|
||||
assert_eq!(prompt_blocks.len(), 1);
|
||||
let text = match &prompt_blocks[0] {
|
||||
acp::ContentBlock::Text(t) => &t.text,
|
||||
_ => panic!("expected Text block"),
|
||||
};
|
||||
assert!(text.contains("image_gen"));
|
||||
assert!(text.contains("a golden sunset"));
|
||||
}
|
||||
other => panic!("expected InjectSkill, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_tools::implementations::grok_build::{
|
||||
IMAGE_TO_VIDEO_TOOL_NAME, IMAGINE_VIDEO_COMMAND_NAME, imagine_video_instruction,
|
||||
imagine_video_usage_message,
|
||||
};
|
||||
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
const REQUIRED_TOOLS: &[&str] = &[IMAGE_TO_VIDEO_TOOL_NAME];
|
||||
|
||||
pub struct ImagineVideoCommand;
|
||||
|
||||
impl SlashCommand for ImagineVideoCommand {
|
||||
fn name(&self) -> &str {
|
||||
IMAGINE_VIDEO_COMMAND_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Generate a video from a text description"
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/imagine-video <description>"
|
||||
}
|
||||
|
||||
fn takes_args(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn args_required(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn arg_placeholder(&self) -> Option<&str> {
|
||||
Some("description of the video to generate")
|
||||
}
|
||||
|
||||
fn required_tools(&self) -> &[&str] {
|
||||
REQUIRED_TOOLS
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
let prompt = args.trim();
|
||||
if prompt.is_empty() {
|
||||
return CommandResult::Message(imagine_video_usage_message().to_string());
|
||||
}
|
||||
|
||||
CommandResult::InjectSkill {
|
||||
display_text: format!("/imagine-video {prompt}"),
|
||||
prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(
|
||||
imagine_video_instruction(prompt),
|
||||
))],
|
||||
display_as_skill: false,
|
||||
scheduled_task_preview: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn requires_image_to_video_tool() {
|
||||
assert_eq!(ImagineVideoCommand.required_tools(), &["image_to_video"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prompt_returns_usage() {
|
||||
let models = crate::acp::model_state::ModelState::default();
|
||||
let mut ctx = super::super::tests::make_ctx(&models);
|
||||
let result = ImagineVideoCommand.run(&mut ctx, "");
|
||||
assert!(matches!(result, CommandResult::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_prompt_returns_usage() {
|
||||
let models = crate::acp::model_state::ModelState::default();
|
||||
let mut ctx = super::super::tests::make_ctx(&models);
|
||||
let result = ImagineVideoCommand.run(&mut ctx, " ");
|
||||
assert!(matches!(result, CommandResult::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_prompt_returns_inject_skill() {
|
||||
let models = crate::acp::model_state::ModelState::default();
|
||||
let mut ctx = super::super::tests::make_ctx(&models);
|
||||
let result = ImagineVideoCommand.run(&mut ctx, "a cat playing piano");
|
||||
match result {
|
||||
CommandResult::InjectSkill {
|
||||
display_text,
|
||||
prompt_blocks,
|
||||
display_as_skill,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(display_text, "/imagine-video a cat playing piano");
|
||||
assert!(!display_as_skill);
|
||||
assert_eq!(prompt_blocks.len(), 1);
|
||||
let text = match &prompt_blocks[0] {
|
||||
acp::ContentBlock::Text(t) => &t.text,
|
||||
_ => panic!("expected Text block"),
|
||||
};
|
||||
assert!(
|
||||
text.contains("image_to_video"),
|
||||
"skill should reference image_to_video"
|
||||
);
|
||||
assert!(
|
||||
text.contains("reference_to_video"),
|
||||
"skill should reference reference_to_video"
|
||||
);
|
||||
assert!(text.contains("a cat playing piano"));
|
||||
}
|
||||
other => panic!("expected InjectSkill, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,6 @@ pub mod gboom;
|
||||
pub mod help;
|
||||
pub mod history;
|
||||
pub mod home;
|
||||
pub mod imagine;
|
||||
pub mod imagine_video;
|
||||
pub mod import_claude;
|
||||
pub mod jump;
|
||||
pub mod login;
|
||||
@@ -111,8 +109,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
|
||||
Arc::new(recap::RecapCommand),
|
||||
Arc::new(terminal_setup::TerminalSetupCommand),
|
||||
Arc::new(loop_cmd::LoopCommand),
|
||||
Arc::new(imagine::ImagineCommand),
|
||||
Arc::new(imagine_video::ImagineVideoCommand),
|
||||
Arc::new(timestamps::TimestampsCommand),
|
||||
Arc::new(timeline::TimelineCommand),
|
||||
Arc::new(toggle_mouse_reporting::ToggleMouseReportingCommand),
|
||||
|
||||
@@ -2555,19 +2555,19 @@ mod tests {
|
||||
let state = SlashState::default();
|
||||
let models = ModelState::default();
|
||||
|
||||
let text = "hi /imagine\n\n /execute-plan";
|
||||
let cursor = text.find("/imagine").unwrap() + "/imagine".len();
|
||||
let text = "hi /compact\n\n /execute-plan";
|
||||
let cursor = text.find("/compact").unwrap() + "/compact".len();
|
||||
ctrl.refresh(&state, text, cursor, &models);
|
||||
let snapshot = state.snapshot();
|
||||
assert!(
|
||||
snapshot.cursor_in_command,
|
||||
"cursor at end of /imagine must stay in command mode for Tab"
|
||||
"cursor at end of /compact must stay in command mode for Tab"
|
||||
);
|
||||
assert_eq!(snapshot.args_range, None);
|
||||
assert_eq!(
|
||||
snapshot.command_range,
|
||||
Some(3..11),
|
||||
"Tab must target /imagine, not the later /execute-plan token"
|
||||
"Tab must target /compact, not the later /execute-plan token"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1106,7 +1106,6 @@ fn render_location_picker(
|
||||
badge: badge.as_str(),
|
||||
badge_color: (!badge.is_empty()).then_some(theme.accent_user),
|
||||
collapsible: false,
|
||||
underline_last_desc: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -455,8 +455,6 @@ pub enum ButtonAction {
|
||||
ReloadSkills,
|
||||
/// Refresh MCP server list (re-fetch from shell).
|
||||
RefreshMcpList,
|
||||
/// Open grok.com connectors page (MCP tab: press `o`).
|
||||
OpenManagedConnectors,
|
||||
/// Update (fetch latest from source) the selected plugin.
|
||||
UpdateSelectedPlugin,
|
||||
/// Uninstall the selected plugin.
|
||||
@@ -1586,8 +1584,6 @@ pub struct ExtensionsModalState {
|
||||
/// should be mutated by input handlers; the window's copy is a
|
||||
/// rendering hint synced each frame.
|
||||
pub active_tab: ExtensionsTab,
|
||||
/// Session team principal for managed-connectors deep links in section copy.
|
||||
pub session_team_id: Option<String>,
|
||||
/// Hooks list data (fetched from shell).
|
||||
pub hooks_data: TabDataState<kigi_hooks_plugins_types::HooksListResponse>,
|
||||
/// Plugins list data (fetched from shell).
|
||||
@@ -1683,7 +1679,6 @@ impl ExtensionsModalState {
|
||||
Self {
|
||||
window: ModalWindowState::with_tabs(ExtensionsTab::ALL.len()),
|
||||
active_tab: tab,
|
||||
session_team_id: None,
|
||||
hooks_data: TabDataState::Loading,
|
||||
plugins_data: TabDataState::Loading,
|
||||
button_areas: Vec::new(),
|
||||
@@ -2544,8 +2539,7 @@ pub fn render_extensions_modal(
|
||||
ExtensionsTab::McpServers => {
|
||||
if let TabDataState::Loaded(ref servers) = state.mcps_data {
|
||||
use crate::views::mcps_modal::{
|
||||
McpSectionId, section_description_lines, section_for, section_key,
|
||||
section_label,
|
||||
McpSectionId, section_for, section_key, section_label,
|
||||
};
|
||||
|
||||
init_mcps_section_collapse_on_first_load(
|
||||
@@ -2582,10 +2576,7 @@ pub fn render_extensions_modal(
|
||||
);
|
||||
entry_labels.push(section_label(section_id, section_servers.len()));
|
||||
entry_right_labels.push(String::new());
|
||||
entry_desc_lines.push(section_description_lines(
|
||||
section_id,
|
||||
state.session_team_id.as_deref(),
|
||||
));
|
||||
entry_desc_lines.push(vec![]);
|
||||
entry_summary_lines.push(vec![]);
|
||||
entry_fields.push(vec![]);
|
||||
entry_is_header.push(false);
|
||||
@@ -2990,20 +2981,6 @@ pub fn render_extensions_modal(
|
||||
Rect::new(content_area.x, content_area.y, content_area.width, 1)
|
||||
};
|
||||
|
||||
// Underline the Managed section's last description line (the connectors URL) as a link affordance.
|
||||
let managed_section_key =
|
||||
crate::views::mcps_modal::section_key(&crate::views::mcps_modal::McpSectionId::Managed);
|
||||
// `underline_last_desc` and the recorded click band both assume the URL is the
|
||||
// LAST Managed description line; trip a test if that ever stops holding.
|
||||
debug_assert!(
|
||||
crate::views::mcps_modal::section_description_lines(
|
||||
&crate::views::mcps_modal::McpSectionId::Managed,
|
||||
state.session_team_id.as_deref(),
|
||||
)
|
||||
.last()
|
||||
.is_some_and(|l| l.starts_with('[') && l.ends_with(']')),
|
||||
"Managed section's last description line must be the bracketed connectors URL",
|
||||
);
|
||||
let picker_entries: Vec<picker::PickerEntry<'_>> = entry_labels
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -3039,7 +3016,6 @@ pub fn render_extensions_modal(
|
||||
badge: entry_badge_text.get(i).map(|s| s.as_str()).unwrap_or(""),
|
||||
badge_color: entry_badge_color.get(i).copied().flatten(),
|
||||
collapsible: is_collapsible,
|
||||
underline_last_desc: group_key.is_some_and(|k| *k == managed_section_key),
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -3062,7 +3038,6 @@ pub fn render_extensions_modal(
|
||||
// below own the entries area instead.
|
||||
let (item_rects, entry_indices) = if in_input_mode {
|
||||
// No picker render in input mode: clear any stale recorded link band.
|
||||
state.picker_state.link_band = None;
|
||||
(Vec::new(), Vec::new())
|
||||
} else {
|
||||
let content_hit = picker::render_picker_content_with_scrollbar_x(
|
||||
@@ -3502,7 +3477,7 @@ mod tests {
|
||||
let mask = build_entry_non_selectable(
|
||||
&[false, false, true],
|
||||
&[
|
||||
Some("mcp-section:managed".into()),
|
||||
Some("mcp-section:local".into()),
|
||||
Some("mcp-tools:0".into()),
|
||||
None,
|
||||
],
|
||||
@@ -3519,7 +3494,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_entry_non_selectable_clickable_is_empty_for_mcp_sections() {
|
||||
let mask = build_entry_non_selectable_clickable(&[
|
||||
Some("mcp-section:managed".into()),
|
||||
Some("mcp-section:local".into()),
|
||||
Some("mcp-tools:0".into()),
|
||||
None,
|
||||
]);
|
||||
@@ -3568,8 +3543,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture layout (managed section, two servers with tools):
|
||||
// 0 section header group_key=Some("mcp-section:managed") data=None
|
||||
// Fixture layout (local section, two servers with tools):
|
||||
// 0 section header group_key=Some("mcp-section:local") data=None
|
||||
// 1 server 0 header group_key=Some("mcp-tools:0") data=Some(0)
|
||||
// 2 tool 0 of svr 0 group_key=None data=Some(0)
|
||||
// 3 tool 1 of svr 0 group_key=None data=Some(0)
|
||||
@@ -3579,7 +3554,7 @@ mod tests {
|
||||
let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers);
|
||||
state.entry_data_indices = vec![None, Some(0), Some(0), Some(0), Some(1), Some(1)];
|
||||
state.entry_group_keys = vec![
|
||||
Some("mcp-section:managed".to_string()),
|
||||
Some("mcp-section:local".to_string()),
|
||||
Some("mcp-tools:0".to_string()),
|
||||
None,
|
||||
None,
|
||||
@@ -3612,12 +3587,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_section_managed_collapsed_hides_child_servers() {
|
||||
fn mcp_section_local_collapsed_hides_child_servers() {
|
||||
let mut collapsed = std::collections::HashSet::new();
|
||||
collapsed.insert("mcp-section:managed".to_string());
|
||||
collapsed.insert("mcp-section:local".to_string());
|
||||
assert!(mcp_section_children_hidden(
|
||||
&collapsed,
|
||||
"mcp-section:managed",
|
||||
"mcp-section:local",
|
||||
false
|
||||
));
|
||||
}
|
||||
@@ -3625,10 +3600,10 @@ mod tests {
|
||||
#[test]
|
||||
fn mcp_section_search_forces_children_visible() {
|
||||
let mut collapsed = std::collections::HashSet::new();
|
||||
collapsed.insert("mcp-section:managed".to_string());
|
||||
collapsed.insert("mcp-section:local".to_string());
|
||||
assert!(!mcp_section_children_hidden(
|
||||
&collapsed,
|
||||
"mcp-section:managed",
|
||||
"mcp-section:local",
|
||||
true
|
||||
));
|
||||
}
|
||||
@@ -3638,9 +3613,9 @@ mod tests {
|
||||
let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers);
|
||||
state
|
||||
.mcps_collapsed_sections
|
||||
.insert("mcp-section:managed".to_string());
|
||||
.insert("mcp-section:local".to_string());
|
||||
state.picker_state.query = "linear".into();
|
||||
assert!(state.is_group_expanded(0, "mcp-section:managed"));
|
||||
assert!(state.is_group_expanded(0, "mcp-section:local"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3655,7 +3630,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mcp_auth_intercept_on_expand_detects_auth_required_server() {
|
||||
use crate::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo, McpWireSource};
|
||||
use crate::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo};
|
||||
|
||||
let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers);
|
||||
state.mcps_data = TabDataState::Loaded(vec![McpServerInfo {
|
||||
@@ -3666,16 +3641,11 @@ mod tests {
|
||||
auth_required: true,
|
||||
tools: vec![],
|
||||
enabled: true,
|
||||
source: "managed".into(),
|
||||
wire_source: McpWireSource::Managed,
|
||||
source: "local".into(),
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
}]);
|
||||
state.entry_data_indices = vec![None, Some(0)];
|
||||
state.entry_group_keys = vec![
|
||||
Some("mcp-section:managed".into()),
|
||||
Some("mcp-tools:0".into()),
|
||||
];
|
||||
state.entry_group_keys = vec![Some("mcp-section:local".into()), Some("mcp-tools:0".into())];
|
||||
state.picker_state.selected = 1;
|
||||
assert!(
|
||||
state.mcp_auth_intercept_on_expand(),
|
||||
@@ -3692,7 +3662,6 @@ mod tests {
|
||||
|
||||
fn make_mcp_server_for_rows(
|
||||
name: &str,
|
||||
wire: crate::views::mcps_modal::McpWireSource,
|
||||
tools: Vec<(&str, bool)>,
|
||||
) -> crate::views::mcps_modal::McpServerInfo {
|
||||
use crate::views::mcps_modal::{McpServerDisplayStatus, McpToolDetail};
|
||||
@@ -3715,22 +3684,15 @@ mod tests {
|
||||
tools: tool_details,
|
||||
enabled: true,
|
||||
source: "local".into(),
|
||||
wire_source: wire,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_collapsed_managed_section_omits_server_rows() {
|
||||
use crate::views::mcps_modal::McpWireSource;
|
||||
|
||||
let servers = vec![
|
||||
make_mcp_server_for_rows("grok_com_linear", McpWireSource::Managed, vec![]),
|
||||
make_mcp_server_for_rows("local-srv", McpWireSource::Local, vec![]),
|
||||
];
|
||||
fn mcp_collapsed_local_section_omits_server_rows() {
|
||||
let servers = vec![make_mcp_server_for_rows("local-srv", vec![])];
|
||||
let mut collapsed = std::collections::HashSet::new();
|
||||
collapsed.insert("mcp-section:managed".to_string());
|
||||
collapsed.insert("mcp-section:local".to_string());
|
||||
let rows = build_mcp_servers_picker_rows(
|
||||
&servers,
|
||||
"",
|
||||
@@ -3738,34 +3700,21 @@ mod tests {
|
||||
&collapsed,
|
||||
&std::collections::HashSet::new(),
|
||||
);
|
||||
assert!(
|
||||
rows.labels
|
||||
.iter()
|
||||
.any(|l| l.starts_with("Managed by grok.com")),
|
||||
"managed section header must appear"
|
||||
);
|
||||
assert!(
|
||||
!rows.labels.iter().any(|l| l == "grok_com_linear"),
|
||||
"servers in collapsed managed section must be omitted"
|
||||
);
|
||||
assert!(
|
||||
rows.labels.iter().any(|l| l.starts_with("Local")),
|
||||
"local section should still render"
|
||||
"local section header must appear"
|
||||
);
|
||||
assert!(
|
||||
!rows.labels.iter().any(|l| l == "local-srv"),
|
||||
"servers in collapsed local section must be omitted"
|
||||
);
|
||||
assert!(rows.labels.iter().any(|l| l == "local-srv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_rows_emitted_when_tools_expanded_by_server_index() {
|
||||
use crate::views::mcps_modal::McpWireSource;
|
||||
|
||||
let servers = vec![
|
||||
make_mcp_server_for_rows(
|
||||
"alpha",
|
||||
McpWireSource::Managed,
|
||||
vec![("tool-a1", true), ("tool-a2", true)],
|
||||
),
|
||||
make_mcp_server_for_rows("beta", McpWireSource::Managed, vec![("tool-b1", true)]),
|
||||
make_mcp_server_for_rows("alpha", vec![("tool-a1", true), ("tool-a2", true)]),
|
||||
make_mcp_server_for_rows("beta", vec![("tool-b1", true)]),
|
||||
];
|
||||
let mut tools_expanded = std::collections::HashSet::new();
|
||||
tools_expanded.insert(0);
|
||||
@@ -3792,7 +3741,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mcps_plugin_sections_collapsed_on_first_load() {
|
||||
use crate::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo, McpWireSource};
|
||||
use crate::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo};
|
||||
|
||||
let servers = vec![
|
||||
McpServerInfo {
|
||||
@@ -3804,9 +3753,7 @@ mod tests {
|
||||
tools: vec![],
|
||||
enabled: true,
|
||||
source: "plugin: alpha".into(),
|
||||
wire_source: McpWireSource::Local,
|
||||
plugin_name: Some("alpha".into()),
|
||||
is_managed_gateway: false,
|
||||
},
|
||||
McpServerInfo {
|
||||
name: "p2-srv".into(),
|
||||
@@ -3817,9 +3764,7 @@ mod tests {
|
||||
tools: vec![],
|
||||
enabled: true,
|
||||
source: "plugin: beta".into(),
|
||||
wire_source: McpWireSource::Local,
|
||||
plugin_name: Some("beta".into()),
|
||||
is_managed_gateway: false,
|
||||
},
|
||||
];
|
||||
let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers);
|
||||
@@ -3827,11 +3772,6 @@ mod tests {
|
||||
!state.mcps_collapsed_sections.contains("mcp-section:local"),
|
||||
"Local section starts expanded by default for a less noisy initial view"
|
||||
);
|
||||
assert!(
|
||||
!state
|
||||
.mcps_collapsed_sections
|
||||
.contains("mcp-section:managed")
|
||||
);
|
||||
init_mcps_section_collapse_on_first_load(
|
||||
&mut state.mcps_collapsed_sections,
|
||||
&mut state.mcps_section_collapse_initialized,
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
//! MCP server data types, status enum, response conversion, and section
|
||||
//! presentation helpers (labels, description lines, connectors URLs).
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum McpWireSource {
|
||||
Managed,
|
||||
Local,
|
||||
}
|
||||
//! presentation helpers (labels).
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum McpSectionId {
|
||||
Managed,
|
||||
Plugin(String),
|
||||
Local,
|
||||
}
|
||||
@@ -24,9 +17,6 @@ impl Ord for McpSectionId {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
use std::cmp::Ordering;
|
||||
match (self, other) {
|
||||
(Self::Managed, Self::Managed) => Ordering::Equal,
|
||||
(Self::Managed, _) => Ordering::Less,
|
||||
(_, Self::Managed) => Ordering::Greater,
|
||||
(Self::Plugin(a), Self::Plugin(b)) => a.cmp(b),
|
||||
(Self::Plugin(_), Self::Local) => Ordering::Less,
|
||||
(Self::Local, Self::Plugin(_)) => Ordering::Greater,
|
||||
@@ -38,86 +28,28 @@ impl Ord for McpSectionId {
|
||||
/// Collapse/expand key for a section header row in the MCP servers tab.
|
||||
pub fn section_key(section: &McpSectionId) -> String {
|
||||
match section {
|
||||
McpSectionId::Managed => "mcp-section:managed".into(),
|
||||
McpSectionId::Plugin(name) => format!("mcp-section:plugin:{name}"),
|
||||
McpSectionId::Local => "mcp-section:local".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Display label for a section header, e.g. `"Managed by grok.com (3)"`.
|
||||
/// Display label for a section header, e.g. `"Local (3)"`.
|
||||
pub fn section_label(section: &McpSectionId, count: usize) -> String {
|
||||
match section {
|
||||
McpSectionId::Managed => format!("Managed by grok.com ({count})"),
|
||||
McpSectionId::Plugin(name) => format!("Plugin: {name} ({count})"),
|
||||
McpSectionId::Local => format!("Local ({count})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Base grok.com connectors URL (no team). Prefer [`managed_connectors_url`] when opening.
|
||||
pub const MANAGED_SECTION_CONNECTORS_URL: &str = "https://grok.com/connectors";
|
||||
|
||||
/// Connectors deep link, appending percent-encoded `teamId` when the session is a team principal.
|
||||
pub fn managed_connectors_url(team_id: Option<&str>) -> String {
|
||||
match team_id.filter(|id| !id.is_empty()) {
|
||||
Some(id) => format!(
|
||||
"{MANAGED_SECTION_CONNECTORS_URL}?teamId={}",
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
None => MANAGED_SECTION_CONNECTORS_URL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Display form of [`managed_connectors_url`] with the `https://` scheme dropped.
|
||||
///
|
||||
/// Used for the Managed section subtitle so the URL is shorter and more likely
|
||||
/// to fit on one row; the Ctrl+O action still opens the full-scheme URL.
|
||||
pub fn managed_connectors_url_display(team_id: Option<&str>) -> String {
|
||||
let url = managed_connectors_url(team_id);
|
||||
url.strip_prefix("https://").unwrap_or(&url).to_string()
|
||||
}
|
||||
|
||||
/// Description lines shown under the Managed section header (when expanded).
|
||||
/// `team_id` matches the Ctrl+O / open-connectors deep link for the session.
|
||||
pub fn section_description_lines(section: &McpSectionId, team_id: Option<&str>) -> Vec<String> {
|
||||
match section {
|
||||
McpSectionId::Managed => {
|
||||
let url = managed_connectors_url_display(team_id);
|
||||
vec![
|
||||
"Add, remove, or manage connectors. Ctrl+O to open or go to:".into(),
|
||||
format!("[{url}]"),
|
||||
]
|
||||
}
|
||||
McpSectionId::Plugin(_) | McpSectionId::Local => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a server into a UI section.
|
||||
///
|
||||
/// Priority: `grok_com_` prefix or managed wire source → Managed; else plugin
|
||||
/// label → Plugin; else Local. A managed server with a plugin display label
|
||||
/// still lands in Managed.
|
||||
/// Classify a server into a UI section: plugin label → Plugin; else Local.
|
||||
pub fn section_for(server: &McpServerInfo) -> McpSectionId {
|
||||
if server.name.starts_with("grok_com_") || server.wire_source == McpWireSource::Managed {
|
||||
McpSectionId::Managed
|
||||
} else if let Some(ref name) = server.plugin_name {
|
||||
if let Some(ref name) = server.plugin_name {
|
||||
McpSectionId::Plugin(name.clone())
|
||||
} else {
|
||||
McpSectionId::Local
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user may delete this server from local config.
|
||||
pub fn is_removable(server: &McpServerInfo) -> bool {
|
||||
server.wire_source == McpWireSource::Local && !server.name.starts_with("grok_com_")
|
||||
}
|
||||
|
||||
fn parse_wire_source(raw: Option<&str>) -> McpWireSource {
|
||||
match raw {
|
||||
Some("managed") => McpWireSource::Managed,
|
||||
_ => McpWireSource::Local,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_plugin_name(source_label: &str) -> Option<String> {
|
||||
let rest = source_label.strip_prefix("plugin:")?.trim();
|
||||
if rest.is_empty() {
|
||||
@@ -181,11 +113,8 @@ pub struct McpServerInfo {
|
||||
pub enabled: bool,
|
||||
/// Display label from `source_label` or wire `source` (e.g. `"plugin: foo"`).
|
||||
pub source: String,
|
||||
/// Wire `source` enum before display overlay.
|
||||
pub wire_source: McpWireSource,
|
||||
/// Plugin name parsed from `source_label` (`"plugin: …"`).
|
||||
pub plugin_name: Option<String>,
|
||||
pub is_managed_gateway: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -262,10 +191,7 @@ pub fn convert_list_response(resp: McpsListResponse) -> Vec<McpServerInfo> {
|
||||
} else {
|
||||
(McpServerDisplayStatus::Unavailable, 0, vec![], false, false)
|
||||
};
|
||||
let wire_source = parse_wire_source(entry.source.as_deref());
|
||||
let plugin_name = entry.source_label.as_deref().and_then(parse_plugin_name);
|
||||
let is_managed_gateway = entry.name.starts_with("managed_gateway:")
|
||||
|| entry.config_type.as_deref() == Some("managedGateway");
|
||||
let source = entry
|
||||
.source_label
|
||||
.or(entry.source)
|
||||
@@ -279,19 +205,16 @@ pub fn convert_list_response(resp: McpsListResponse) -> Vec<McpServerInfo> {
|
||||
tools,
|
||||
enabled,
|
||||
source,
|
||||
wire_source,
|
||||
plugin_name,
|
||||
is_managed_gateway,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Stable sort: managed before plugin/local, then alphabetical by name.
|
||||
// Stable sort: plugin before local, then alphabetical by name.
|
||||
servers.sort_by(|a, b| {
|
||||
let source_rank = |s: &McpServerInfo| match section_for(s) {
|
||||
McpSectionId::Managed => 0,
|
||||
McpSectionId::Plugin(_) => 1,
|
||||
McpSectionId::Local => 2,
|
||||
McpSectionId::Plugin(_) => 0,
|
||||
McpSectionId::Local => 1,
|
||||
};
|
||||
source_rank(a)
|
||||
.cmp(&source_rank(b))
|
||||
@@ -354,9 +277,7 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
enabled: true,
|
||||
source: "local".to_string(),
|
||||
wire_source: McpWireSource::Local,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,71 +315,6 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_description_lines_managed_includes_connectors_url() {
|
||||
let lines = section_description_lines(&McpSectionId::Managed, None);
|
||||
assert_eq!(lines.len(), 2);
|
||||
// Instruction leads; Ctrl+O hint lives on the first line.
|
||||
assert!(
|
||||
lines[0].contains("Ctrl+O"),
|
||||
"should mention Ctrl+O shortcut: {}",
|
||||
lines[0]
|
||||
);
|
||||
// URL sits alone on the second line, scheme-stripped and bracket-highlighted.
|
||||
assert_eq!(lines[1], "[grok.com/connectors]");
|
||||
assert!(
|
||||
!lines[1].contains("https://"),
|
||||
"displayed URL should drop the scheme: {}",
|
||||
lines[1]
|
||||
);
|
||||
let with_team = section_description_lines(&McpSectionId::Managed, Some("team-1"));
|
||||
assert_eq!(with_team[1], "[grok.com/connectors?teamId=team-1]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_connectors_url_display_strips_scheme() {
|
||||
assert_eq!(managed_connectors_url_display(None), "grok.com/connectors");
|
||||
assert_eq!(
|
||||
managed_connectors_url_display(Some("team-uuid-1")),
|
||||
"grok.com/connectors?teamId=team-uuid-1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_connectors_url_appends_team_id_when_present() {
|
||||
assert_eq!(managed_connectors_url(None), MANAGED_SECTION_CONNECTORS_URL);
|
||||
assert_eq!(
|
||||
managed_connectors_url(Some("")),
|
||||
MANAGED_SECTION_CONNECTORS_URL
|
||||
);
|
||||
assert_eq!(
|
||||
managed_connectors_url(Some("team-uuid-1")),
|
||||
format!("{MANAGED_SECTION_CONNECTORS_URL}?teamId=team-uuid-1")
|
||||
);
|
||||
assert_eq!(
|
||||
managed_connectors_url(Some("a b/c")),
|
||||
format!(
|
||||
"{MANAGED_SECTION_CONNECTORS_URL}?teamId={}",
|
||||
urlencoding::encode("a b/c")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_description_lines_local_is_empty() {
|
||||
assert!(section_description_lines(&McpSectionId::Local, None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_for_grok_com_with_plugin_label_is_managed() {
|
||||
let server = server_from_wire(
|
||||
"grok_com_linear",
|
||||
Some("managed"),
|
||||
Some("plugin: my-plugin"),
|
||||
);
|
||||
assert_eq!(section_for(&server), McpSectionId::Managed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_for_plugin_labeled_local_is_plugin_section() {
|
||||
let server = server_from_wire("my-mcp", Some("local"), Some("plugin: linter"));
|
||||
@@ -468,86 +324,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_removable_plugin_labeled_local_server() {
|
||||
let server = server_from_wire("my-mcp", Some("local"), Some("plugin: linter"));
|
||||
assert!(is_removable(&server));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_removable_rejects_managed_wire_source() {
|
||||
let server = server_from_wire("custom", Some("managed"), None);
|
||||
assert!(!is_removable(&server));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_removable_rejects_grok_com_prefix() {
|
||||
let server = server_from_wire("grok_com_slack", Some("local"), None);
|
||||
assert!(!is_removable(&server));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_list_response_parses_plugin_name() {
|
||||
let server = server_from_wire("srv", Some("local"), Some("plugin: example"));
|
||||
assert_eq!(server.wire_source, McpWireSource::Local);
|
||||
assert_eq!(server.plugin_name.as_deref(), Some("example"));
|
||||
assert_eq!(server.source, "plugin: example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_list_response_classifies_managed_gateway_only_for_gateway_rows() {
|
||||
let gateway = server_from_wire_with_type(
|
||||
"managed_gateway:linear",
|
||||
Some("managed"),
|
||||
None,
|
||||
Some("managedGateway"),
|
||||
);
|
||||
assert!(gateway.is_managed_gateway);
|
||||
|
||||
let legacy_managed = server_from_wire("grok_com_slack", Some("managed"), None);
|
||||
assert!(!legacy_managed.is_managed_gateway);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_row_uses_managed_section_not_local_uninstall() {
|
||||
let gateway = server_from_wire_with_type(
|
||||
"managed_gateway:linear",
|
||||
Some("managed"),
|
||||
None,
|
||||
Some("managedGateway"),
|
||||
);
|
||||
assert_eq!(section_for(&gateway), McpSectionId::Managed);
|
||||
assert!(!is_removable(&gateway));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_list_response_orders_gateway_rows_by_display_name() {
|
||||
fn gateway_entry(name: &str, display_name: &str) -> McpsServerEntry {
|
||||
McpsServerEntry {
|
||||
name: name.to_string(),
|
||||
display_name: Some(display_name.to_string()),
|
||||
source: Some("managed".to_string()),
|
||||
source_label: None,
|
||||
config_type: Some("managedGateway".to_string()),
|
||||
session: Some(McpsServerSession {
|
||||
enabled: true,
|
||||
status: Some("ready".to_string()),
|
||||
tools: vec![],
|
||||
auth_required: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
let servers = convert_list_response(McpsListResponse {
|
||||
servers: vec![
|
||||
gateway_entry("managed_gateway:zeta", "Alpha"),
|
||||
gateway_entry("managed_gateway:alpha", "Zeta"),
|
||||
],
|
||||
});
|
||||
assert_eq!(servers[0].display_name.as_deref(), Some("Alpha"));
|
||||
assert_eq!(servers[0].name, "managed_gateway:zeta");
|
||||
assert_eq!(servers[1].display_name.as_deref(), Some("Zeta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_server_row_updates_existing() {
|
||||
let mut servers = vec![
|
||||
@@ -614,9 +397,7 @@ mod tests {
|
||||
}],
|
||||
enabled: true,
|
||||
source: "local".into(),
|
||||
wire_source: McpWireSource::Local,
|
||||
plugin_name: None,
|
||||
is_managed_gateway: false,
|
||||
}];
|
||||
let mutated = patch_server_row(
|
||||
&mut servers,
|
||||
|
||||
@@ -1089,7 +1089,6 @@ pub fn render_doc_picker_overlay(
|
||||
badge: "",
|
||||
badge_color: None,
|
||||
collapsible: false,
|
||||
underline_last_desc: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -89,9 +89,6 @@ pub struct PickerRow<'a> {
|
||||
/// `description_lines` are empty. The `expanded` field controls
|
||||
/// which glyph is rendered.
|
||||
pub collapsible: bool,
|
||||
/// Underline the final description line (when expanded) so it reads as a
|
||||
/// clickable link. Used for the Managed connectors URL.
|
||||
pub underline_last_desc: bool,
|
||||
}
|
||||
|
||||
/// A key-value pair shown in an expanded picker row.
|
||||
@@ -725,11 +722,9 @@ pub fn compute_row_height(row: &PickerRow<'_>, width: u16) -> usize {
|
||||
/// When `row.expanded && !row.fields.is_empty()`, renders key-value detail lines
|
||||
/// below (indented, label in `gray`, value in `gray_bright`).
|
||||
///
|
||||
/// Rows consumed by a rendered picker row/entry, plus the row band of its
|
||||
/// underlined link line (recorded from what is painted) for click hit-testing.
|
||||
/// Rows consumed by a rendered picker row/entry.
|
||||
pub struct RenderedRow {
|
||||
pub rows: u16,
|
||||
pub link_band: Option<std::ops::Range<u16>>,
|
||||
}
|
||||
|
||||
/// `max_rows` caps rendering to available vertical space; detail fields beyond
|
||||
@@ -749,10 +744,7 @@ pub fn render_picker_row(
|
||||
max_rows: u16,
|
||||
) -> RenderedRow {
|
||||
if max_rows == 0 {
|
||||
return RenderedRow {
|
||||
rows: 0,
|
||||
link_band: None,
|
||||
};
|
||||
return RenderedRow { rows: 0 };
|
||||
}
|
||||
let base_bg = picker_base_bg(bg, theme);
|
||||
let embed = crate::views::modal_window::embedded_row_style(theme, row.selected);
|
||||
@@ -894,7 +886,6 @@ pub fn render_picker_row(
|
||||
|
||||
// Description lines (shown when expanded) or summary lines (collapsed).
|
||||
let mut rows = 1u16;
|
||||
let mut link_band: Option<std::ops::Range<u16>> = None;
|
||||
let secondary_lines: &[&str] = if row.expanded {
|
||||
row.description_lines
|
||||
} else {
|
||||
@@ -907,18 +898,12 @@ pub fn render_picker_row(
|
||||
.fg(theme.text_primary)
|
||||
.bg(base_bg)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
// Underline the final description line when the row opts in, so it reads as a link.
|
||||
let link_style = highlight_style.add_modifier(Modifier::UNDERLINED);
|
||||
let last_line = secondary_lines.len().saturating_sub(1);
|
||||
let max_w = width.saturating_sub(indent) as usize;
|
||||
for (li, desc) in secondary_lines.iter().enumerate() {
|
||||
let is_link = row.underline_last_desc && row.expanded && li == last_line;
|
||||
let hl = if is_link { link_style } else { highlight_style };
|
||||
for desc in secondary_lines.iter() {
|
||||
// Render with [bracket] highlight markers: text inside
|
||||
// [...] is shown in bold/bright, brackets are stripped.
|
||||
let line = parse_highlight_spans(desc, desc_style, hl);
|
||||
let line = parse_highlight_spans(desc, desc_style, highlight_style);
|
||||
let wrapped = word_wrap_line(&line, max_w);
|
||||
let link_start = y + rows;
|
||||
for wrap_line in wrapped {
|
||||
if rows >= max_rows {
|
||||
break;
|
||||
@@ -926,10 +911,6 @@ pub fn render_picker_row(
|
||||
render_styled_spans(buf, &wrap_line, x + indent, y + rows, max_w);
|
||||
rows += 1;
|
||||
}
|
||||
// Record only painted rows, so a vertically-clipped link records nothing.
|
||||
if is_link && y + rows > link_start {
|
||||
link_band = Some(link_start..(y + rows));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1000,7 +981,7 @@ pub fn render_picker_row(
|
||||
}
|
||||
}
|
||||
|
||||
RenderedRow { rows, link_band }
|
||||
RenderedRow { rows }
|
||||
}
|
||||
|
||||
/// Render a single picker entry (header or row).
|
||||
@@ -1019,10 +1000,7 @@ pub fn render_picker_entry(
|
||||
max_rows: u16,
|
||||
) -> RenderedRow {
|
||||
if max_rows == 0 {
|
||||
return RenderedRow {
|
||||
rows: 0,
|
||||
link_band: None,
|
||||
};
|
||||
return RenderedRow { rows: 0 };
|
||||
}
|
||||
match entry {
|
||||
PickerEntry::Header { label } => {
|
||||
@@ -1041,10 +1019,7 @@ pub fn render_picker_entry(
|
||||
Span::styled(sep, sep_style),
|
||||
]);
|
||||
buf.set_line(x, y, &line, width);
|
||||
RenderedRow {
|
||||
rows: 1,
|
||||
link_band: None,
|
||||
}
|
||||
RenderedRow { rows: 1 }
|
||||
}
|
||||
PickerEntry::Row(row) => {
|
||||
render_picker_row(buf, x, y, width, theme, row, hovered, bg, max_rows)
|
||||
@@ -1359,9 +1334,6 @@ pub struct PickerState {
|
||||
pub scroll_offset: Option<usize>,
|
||||
/// Hit areas from the last render (for mouse hit-testing).
|
||||
pub hit_areas: Option<PickerHitAreas>,
|
||||
/// Entry index and absolute row band of the underlined link line from the
|
||||
/// last render (the Managed connectors URL), for click-to-open hit-testing.
|
||||
pub link_band: Option<(usize, std::ops::Range<u16>)>,
|
||||
/// Hit areas for tab labels (one per tab, `None` if tab didn't fit).
|
||||
pub tab_hit_areas: Option<Vec<Option<Rect>>>,
|
||||
/// Hit area for the filter indicator in the search bar.
|
||||
@@ -1387,7 +1359,6 @@ impl Default for PickerState {
|
||||
hovered: None,
|
||||
scroll_offset: None,
|
||||
hit_areas: None,
|
||||
link_band: None,
|
||||
tab_hit_areas: None,
|
||||
filter_area: None,
|
||||
filter_hovered: false,
|
||||
@@ -1427,7 +1398,6 @@ impl PickerState {
|
||||
self.expanded.clear();
|
||||
self.close_hovered = false;
|
||||
self.hit_areas = None;
|
||||
self.link_band = None;
|
||||
self.tab_hit_areas = None;
|
||||
self.filter_area = None;
|
||||
self.filter_hovered = false;
|
||||
@@ -1779,7 +1749,6 @@ fn render_picker_content_inner(
|
||||
scrollbar_x_override: Option<u16>,
|
||||
) -> PickerContentHitAreas {
|
||||
// Cleared each paint; set below if a row underlines its last description line.
|
||||
state.link_band = None;
|
||||
let is_clickable_non_sel = |i: usize| non_selectable_clickable.get(i).copied().unwrap_or(false);
|
||||
let empty_hit = PickerContentHitAreas {
|
||||
item_rects: vec![],
|
||||
@@ -1880,7 +1849,6 @@ fn render_picker_content_inner(
|
||||
|
||||
let mut item_rects = Vec::new();
|
||||
let mut entry_indices = Vec::new();
|
||||
let mut link_band: Option<(usize, std::ops::Range<u16>)> = None;
|
||||
|
||||
// Skip entries until we've consumed scroll_visual visual rows.
|
||||
let mut visual_rows_consumed = 0usize;
|
||||
@@ -1928,9 +1896,6 @@ fn render_picker_content_inner(
|
||||
remaining,
|
||||
);
|
||||
let rows_consumed = rendered.rows;
|
||||
if let Some(band) = rendered.link_band {
|
||||
link_band = Some((entry_idx, band));
|
||||
}
|
||||
|
||||
if !is_header && (!is_non_sel(entry_idx) || is_clickable_non_sel(entry_idx)) {
|
||||
let row_rect = Rect {
|
||||
@@ -1944,8 +1909,6 @@ fn render_picker_content_inner(
|
||||
}
|
||||
y += rows_consumed;
|
||||
}
|
||||
state.link_band = link_band;
|
||||
|
||||
// Scrollbar. (Globally suppressed in minimal mode via
|
||||
// `render::scrollbar::set_scrollbars_hidden`.)
|
||||
if needs_scroll {
|
||||
@@ -3261,76 +3224,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underline_last_desc_underlines_only_the_link_line() {
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
let theme = Theme::current();
|
||||
// Wide enough that each description line fits on a single visual row:
|
||||
// y+0 = label, y+1 = instruction, y+2 = bracket-highlighted URL.
|
||||
let area = Rect::new(0, 0, 60, 6);
|
||||
let desc: &[&str] = &["some instruction text", "[example.com/link]"];
|
||||
|
||||
let render = |underline_last_desc: bool| -> (Buffer, Option<std::ops::Range<u16>>) {
|
||||
let row = PickerRow {
|
||||
label: "Group header",
|
||||
right_label: "",
|
||||
selected: false,
|
||||
expanded: true,
|
||||
fields: &[],
|
||||
description_lines: desc,
|
||||
summary_lines: &[],
|
||||
dimmed: false,
|
||||
indent: 0,
|
||||
badge: "",
|
||||
badge_color: None,
|
||||
collapsible: true,
|
||||
underline_last_desc,
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
let rendered = render_picker_row(
|
||||
&mut buf,
|
||||
area.x,
|
||||
area.y,
|
||||
area.width,
|
||||
&theme,
|
||||
&row,
|
||||
false,
|
||||
None,
|
||||
area.height,
|
||||
);
|
||||
(buf, rendered.link_band)
|
||||
};
|
||||
|
||||
// Rows (relative to area top) that have any underlined cell.
|
||||
let underlined_rows = |buf: &Buffer| -> Vec<u16> {
|
||||
(area.y..area.y + area.height)
|
||||
.filter(|&y| {
|
||||
(0..area.width).any(|x| {
|
||||
buf.cell((x, y))
|
||||
.map(|c| c.modifier.contains(Modifier::UNDERLINED))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let (on, on_band) = render(true);
|
||||
// Only the final (link) description line (row y+2) is underlined; the
|
||||
// label (y+0) and instruction (y+1) rows are not.
|
||||
assert_eq!(underlined_rows(&on), vec![2]);
|
||||
// Render <-> hit-test parity: the recorded band equals the painted rows.
|
||||
assert_eq!(on_band, Some(2..3));
|
||||
|
||||
let (off, off_band) = render(false);
|
||||
assert!(
|
||||
underlined_rows(&off).is_empty(),
|
||||
"opt-out rows underline nothing"
|
||||
);
|
||||
assert_eq!(off_band, None, "opt-out records no link band");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_not_searching_char_does_not_type() {
|
||||
for hint in [true, false] {
|
||||
|
||||
@@ -622,7 +622,6 @@ pub(crate) fn build_grouped_picker_entries<'a>(
|
||||
badge: b.badge,
|
||||
badge_color: None,
|
||||
collapsible: b.collapsible,
|
||||
underline_last_desc: false,
|
||||
}));
|
||||
grouped_pos += 1;
|
||||
}
|
||||
|
||||
@@ -1173,7 +1173,6 @@ impl CheatsheetRows {
|
||||
badge: "",
|
||||
badge_color: None,
|
||||
collapsible: true,
|
||||
underline_last_desc: false,
|
||||
}),
|
||||
CheatsheetRowKind::Hint { dimmed, expand } => {
|
||||
let is_expanded =
|
||||
@@ -1196,7 +1195,6 @@ impl CheatsheetRows {
|
||||
badge: "",
|
||||
badge_color: None,
|
||||
collapsible: false,
|
||||
underline_last_desc: false,
|
||||
})
|
||||
}
|
||||
CheatsheetRowKind::Other => PickerEntry::Row(PickerRow {
|
||||
@@ -1212,7 +1210,6 @@ impl CheatsheetRows {
|
||||
badge: "",
|
||||
badge_color: None,
|
||||
collapsible: false,
|
||||
underline_last_desc: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1854,7 +1854,6 @@ pub(crate) fn render_session_picker(
|
||||
badge: b.badge,
|
||||
badge_color: None,
|
||||
collapsible: b.collapsible,
|
||||
underline_last_desc: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -1938,7 +1937,6 @@ pub(crate) fn render_session_picker(
|
||||
badge: if has_snippet { "match" } else { "" },
|
||||
badge_color: Some(theme.accent_user),
|
||||
collapsible: true,
|
||||
underline_last_desc: false,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user