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:
2026-07-17 23:45:05 -04:00
parent fa75eb139a
commit 5e4e24db99
120 changed files with 301 additions and 11327 deletions
@@ -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>`
+1 -155
View File
@@ -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.
+3 -3
View File
@@ -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),
+4 -4
View File
@@ -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,
+7 -226
View File
@@ -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();
+7 -114
View File
@@ -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,
}));
}