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).
544 lines
20 KiB
Rust
544 lines
20 KiB
Rust
//! OtherToolCallBlock - unknown/generic tool types.
|
|
|
|
use ratatui::text::{Line, Span};
|
|
|
|
use crate::render::wrapping::word_wrap_lines;
|
|
use crate::scrollback::block::BlockContent;
|
|
use crate::scrollback::types::{
|
|
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode,
|
|
};
|
|
use crate::theme::Theme;
|
|
|
|
/// Other/unknown tool call.
|
|
#[derive(Debug, Clone)]
|
|
pub struct OtherToolCallBlock {
|
|
/// Tool name.
|
|
pub name: String,
|
|
/// Summary/target.
|
|
pub summary: String,
|
|
/// Error message if the tool call failed (None = success).
|
|
pub error: Option<String>,
|
|
/// Optional output.
|
|
pub output: Option<String>,
|
|
/// When the tool started running (Phase 2: time tracking).
|
|
pub started_at: Option<std::time::Instant>,
|
|
/// Elapsed time in ms after completion (Phase 2: time tracking).
|
|
pub elapsed_ms: Option<i64>,
|
|
/// Image references detected in the tool output.
|
|
image_refs: Vec<crate::prompt_images::ScrollbackImageRef>,
|
|
/// Video references detected in the tool output.
|
|
video_refs: Vec<crate::prompt_images::ScrollbackVideoRef>,
|
|
}
|
|
|
|
impl OtherToolCallBlock {
|
|
/// Create a new other tool block.
|
|
///
|
|
/// Pre-completed blocks have no meaningful local timing — `started_at`
|
|
/// is `None`. Timing is only set for blocks that enter a running UI
|
|
/// state (via `set_last_running(true)` in `ScrollbackState`).
|
|
pub fn new(name: impl Into<String>, summary: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
summary: summary.into(),
|
|
error: None,
|
|
output: None,
|
|
started_at: None,
|
|
elapsed_ms: None,
|
|
image_refs: Vec::new(),
|
|
video_refs: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Set error (marks as failed).
|
|
pub fn with_error(mut self, error: impl Into<String>) -> Self {
|
|
self.error = Some(error.into());
|
|
self
|
|
}
|
|
|
|
/// Set output (builder).
|
|
pub fn with_output(mut self, output: impl Into<String>) -> Self {
|
|
self.set_output_text(output.into());
|
|
self
|
|
}
|
|
|
|
/// Set or replace the output text.
|
|
pub fn set_output_text(&mut self, text: String) {
|
|
self.image_refs = crate::prompt_images::extract_image_refs(&text);
|
|
self.video_refs = crate::prompt_images::extract_video_refs(&text);
|
|
self.output = Some(text);
|
|
}
|
|
|
|
/// Set the media reference from a typed path (no prose scraping).
|
|
/// `from_path` validates the file and normalizes `\\?\`; an unresolvable
|
|
/// path is a no-op.
|
|
pub fn with_media_ref(mut self, path: impl Into<std::path::PathBuf>, is_video: bool) -> Self {
|
|
let path = path.into();
|
|
if is_video {
|
|
if let Some(r) = crate::prompt_images::ScrollbackVideoRef::from_path(path) {
|
|
self.video_refs = vec![r];
|
|
}
|
|
} else if let Some(r) = crate::prompt_images::ScrollbackImageRef::from_path(path) {
|
|
self.image_refs = vec![r];
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Check if successful (no error).
|
|
pub fn is_success(&self) -> bool {
|
|
self.error.is_none()
|
|
}
|
|
|
|
/// Path of the first media reference (image or video) for the filepath
|
|
/// line of an inline-media block, independent of inline-graphics support.
|
|
pub(crate) fn media_ref_path(&self) -> Option<std::path::PathBuf> {
|
|
if let Some(img) = self.image_refs.first() {
|
|
return Some(img.path.clone());
|
|
}
|
|
if let Some(vid) = self.video_refs.first() {
|
|
return Some(vid.path.clone());
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
|
|
pub fn set_error(&mut self, error: Option<String>) {
|
|
if self.elapsed_ms.is_none()
|
|
&& let Some(start) = self.started_at
|
|
{
|
|
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
|
|
}
|
|
self.error = error;
|
|
}
|
|
|
|
/// Finalize elapsed time from `started_at`.
|
|
///
|
|
/// Idempotent: no-op if `started_at` is `None` (pre-completed block)
|
|
/// or if `elapsed_ms` is already set (already finalized).
|
|
pub fn finish(&mut self) {
|
|
if self.elapsed_ms.is_some() {
|
|
return;
|
|
}
|
|
if let Some(start) = self.started_at {
|
|
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
|
|
}
|
|
}
|
|
|
|
/// Get elapsed time in ms (Phase 2).
|
|
pub fn elapsed_ms(&self) -> Option<i64> {
|
|
match self.elapsed_ms {
|
|
Some(ms) => Some(ms),
|
|
None => self
|
|
.started_at
|
|
.map(|start| start.elapsed().as_millis() as i64),
|
|
}
|
|
}
|
|
|
|
/// Render collapsed line: **`Label`** `content` or **`Name`**.
|
|
///
|
|
/// If the name contains `: `, splits into bold label + muted/primary content
|
|
/// (e.g. "Ask: What is your favorite language?"). Otherwise renders
|
|
/// the full name in bold.
|
|
///
|
|
/// When `muted` is true (collapsed state), all text uses dim styles to
|
|
/// match other collapsed blocks. The label ("Ask") stays bold.
|
|
fn collapsed_line(&self, theme: &Theme, muted: bool, width: Option<usize>) -> Line<'static> {
|
|
let text_style = if muted {
|
|
theme.muted()
|
|
} else {
|
|
theme.primary()
|
|
};
|
|
let bold_style = text_style.add_modifier(ratatui::style::Modifier::BOLD);
|
|
|
|
let mut spans = if let Some((label, content)) = self.name.split_once(": ") {
|
|
vec![
|
|
Span::styled(format!("{} ", label), bold_style),
|
|
Span::styled(content.to_string(), text_style),
|
|
]
|
|
} else {
|
|
vec![Span::styled(self.name.clone(), bold_style)]
|
|
};
|
|
|
|
if !self.summary.is_empty() {
|
|
if let Some(w) = width {
|
|
// Only include summary if there's room.
|
|
let used: usize = spans
|
|
.iter()
|
|
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
|
|
.sum();
|
|
let summary = format!(" {}", self.summary);
|
|
if used + summary.len() <= w {
|
|
spans.push(Span::styled(summary, theme.muted()));
|
|
}
|
|
} else {
|
|
spans.push(Span::styled(format!(" {}", self.summary), theme.muted()));
|
|
}
|
|
}
|
|
|
|
let line = Line::from(spans);
|
|
if let Some(w) = width {
|
|
crate::render::line_utils::truncate_line(line, w)
|
|
} else {
|
|
line
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BlockContent for OtherToolCallBlock {
|
|
fn output(&self, ctx: &BlockContext) -> BlockOutput {
|
|
let theme = Theme::current();
|
|
let width = ctx.width as usize;
|
|
let muted_collapsed =
|
|
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
|
|
|
|
// 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()));
|
|
let max_w = ctx.content_width();
|
|
// Percent-decode for display only (e.g. `%2F` → `/`); the stored
|
|
// path is unchanged so Open / copy-path still target the file.
|
|
let raw_path = media_path.display().to_string();
|
|
let path_str = urlencoding::decode(&raw_path)
|
|
.map(|s| s.into_owned())
|
|
.unwrap_or(raw_path);
|
|
// Char-boundary middle-ellipsis (decoded paths may be multibyte).
|
|
let path_display = if path_str.chars().count() > max_w {
|
|
let keep = max_w.saturating_sub(3) / 2;
|
|
let end_keep = max_w.saturating_sub(3) - keep;
|
|
let chars: Vec<char> = path_str.chars().collect();
|
|
let head: String = chars[..keep].iter().collect();
|
|
let tail: String = chars[chars.len() - end_keep..].iter().collect();
|
|
format!("{head}...{tail}")
|
|
} else {
|
|
path_str
|
|
};
|
|
let path_line = Line::from(Span::styled(
|
|
path_display,
|
|
ratatui::style::Style::default().fg(theme.gray_dim),
|
|
));
|
|
let mut lines: Vec<BlockLine> = vec![header.into(), path_line.into()];
|
|
|
|
// No inline graphics: centered "[Open]" button between blank
|
|
// spacers (its click target is registered in render.rs).
|
|
if let Some((_, is_video)) = self.inline_open_button() {
|
|
let label = crate::scrollback::render::media_open_button_label(is_video);
|
|
let col = crate::scrollback::render::media_open_button_col(
|
|
ctx.content_width() as u16,
|
|
is_video,
|
|
);
|
|
let open_line = Line::from(vec![
|
|
Span::raw(" ".repeat(col as usize)),
|
|
Span::styled(
|
|
label.to_string(),
|
|
ratatui::style::Style::default()
|
|
.fg(theme.md_code)
|
|
.add_modifier(ratatui::style::Modifier::BOLD),
|
|
),
|
|
]);
|
|
lines.push(Line::from("").into());
|
|
lines.push(open_line.into());
|
|
lines.push(Line::from("").into());
|
|
}
|
|
|
|
return BlockOutput { lines };
|
|
}
|
|
|
|
match ctx.mode {
|
|
DisplayMode::Collapsed => BlockOutput {
|
|
lines: vec![
|
|
self.collapsed_line(&theme, muted_collapsed, Some(ctx.content_width()))
|
|
.into(),
|
|
],
|
|
},
|
|
DisplayMode::Truncated | DisplayMode::Expanded => {
|
|
let mut lines: Vec<BlockLine> =
|
|
vec![self.collapsed_line(&theme, false, None).into()];
|
|
|
|
if let Some(output) = &self.output {
|
|
// Try to render as structured Q&A (AskUserQuestion output).
|
|
let qa_lines = parse_ask_user_qa_pairs(output);
|
|
if !qa_lines.is_empty() {
|
|
for (i, (question, answer)) in qa_lines.iter().enumerate() {
|
|
// " 1. question text"
|
|
let q_line = Line::from(vec![
|
|
Span::styled(format!(" {}. ", i + 1), theme.muted()),
|
|
Span::styled(question.clone(), theme.primary()),
|
|
]);
|
|
lines.push(BlockLine::styled(q_line));
|
|
|
|
// " → answer" or " (no answer)"
|
|
let a_line = if answer.is_empty() {
|
|
Line::from(Span::styled(
|
|
" (no answer)".to_string(),
|
|
theme.dim(),
|
|
))
|
|
} else {
|
|
Line::from(vec![
|
|
Span::styled(
|
|
" \u{2192} ".to_string(),
|
|
theme.fg(theme.accent_user),
|
|
),
|
|
Span::styled(answer.clone(), theme.fg(theme.accent_user)),
|
|
])
|
|
};
|
|
lines.push(BlockLine::styled(a_line));
|
|
}
|
|
} else {
|
|
// Generic output rendering (non-Q&A tools).
|
|
lines.push(Line::from("").into());
|
|
|
|
let styled_lines: Vec<Line<'static>> = output
|
|
.lines()
|
|
.map(|line| Line::from(Span::styled(line.to_string(), theme.muted())))
|
|
.collect();
|
|
|
|
let wrapped =
|
|
word_wrap_lines(styled_lines, width.saturating_sub(2).max(20));
|
|
|
|
for wrapped_line in wrapped {
|
|
lines.push(BlockLine::styled(wrapped_line));
|
|
}
|
|
}
|
|
}
|
|
|
|
BlockOutput { lines }
|
|
}
|
|
}
|
|
}
|
|
|
|
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
|
|
// No accent when collapsed — keeps accents reserved for Execute blocks in dense groups
|
|
if ctx.mode == DisplayMode::Collapsed {
|
|
return None;
|
|
}
|
|
let theme = Theme::current();
|
|
if self.error.is_some() {
|
|
Some(AccentStyle::static_color(theme.accent_error))
|
|
} else if ctx.is_running {
|
|
Some(AccentStyle::animated(theme.accent_running))
|
|
} else {
|
|
Some(AccentStyle::static_color(theme.accent_tool))
|
|
}
|
|
}
|
|
|
|
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
|
|
// Failed: red bullet. Running/expanded: accent color. Collapsed: default.
|
|
if self.error.is_some() {
|
|
let theme = Theme::current();
|
|
Some(AccentStyle::static_color(theme.accent_error))
|
|
} else if ctx.mode == DisplayMode::Collapsed {
|
|
None // default gray
|
|
} else {
|
|
self.accent(ctx) // inherit from accent when expanded/running
|
|
}
|
|
}
|
|
|
|
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
|
|
false
|
|
}
|
|
|
|
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
|
|
BlockBackground::None
|
|
}
|
|
|
|
fn has_raw_mode(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn is_foldable(&self) -> bool {
|
|
// Not foldable if failed
|
|
if self.error.is_some() {
|
|
return false;
|
|
}
|
|
self.output.is_some()
|
|
}
|
|
|
|
fn default_display_mode(&self) -> DisplayMode {
|
|
DisplayMode::Collapsed
|
|
}
|
|
|
|
fn next_fold_mode(&self, current: DisplayMode, is_running: bool) -> DisplayMode {
|
|
if is_running {
|
|
match current {
|
|
DisplayMode::Truncated => DisplayMode::Expanded,
|
|
_ => DisplayMode::Truncated,
|
|
}
|
|
} else {
|
|
match current {
|
|
DisplayMode::Collapsed => DisplayMode::Expanded,
|
|
_ => DisplayMode::Collapsed,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collapse_mode(&self, is_running: bool) -> DisplayMode {
|
|
if is_running {
|
|
DisplayMode::Truncated
|
|
} else {
|
|
DisplayMode::Collapsed
|
|
}
|
|
}
|
|
|
|
fn image_references(&self) -> &[crate::prompt_images::ScrollbackImageRef] {
|
|
&self.image_refs
|
|
}
|
|
|
|
fn video_references(&self) -> &[crate::prompt_images::ScrollbackVideoRef] {
|
|
&self.video_refs
|
|
}
|
|
|
|
fn inline_media(&self) -> Option<crate::prompt_images::InlineMediaInfo> {
|
|
if let Some(img) = self.image_refs.first() {
|
|
let (w, h) = img.dimensions?;
|
|
return Some(crate::prompt_images::InlineMediaInfo {
|
|
path: img.path.clone(),
|
|
width: w,
|
|
height: h,
|
|
is_video: false,
|
|
alt_text: img.alt_text.clone(),
|
|
});
|
|
}
|
|
if let Some(vid) = self.video_refs.first() {
|
|
return Some(crate::prompt_images::InlineMediaInfo {
|
|
path: vid.path.clone(),
|
|
width: 1280,
|
|
height: 720,
|
|
is_video: true,
|
|
alt_text: vid.alt_text.clone(),
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
fn inline_open_button(&self) -> Option<(std::path::PathBuf, bool)> {
|
|
// Only used when there is no inline-graphics overlay to host the button
|
|
// row. When the overlay is active it draws its own button row instead.
|
|
if crate::terminal::image::scrollback_inline_overlay_active() {
|
|
return None;
|
|
}
|
|
if let Some(img) = self.image_refs.first() {
|
|
return Some((img.path.clone(), false));
|
|
}
|
|
if let Some(vid) = self.video_refs.first() {
|
|
return Some((vid.path.clone(), true));
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
// ── AskUserQuestion output parser ────────────────────────────────────
|
|
|
|
/// Parse Q&A pairs from an AskUserQuestion tool result string.
|
|
///
|
|
/// Recognizes all three accepted output formats:
|
|
///
|
|
/// **Path A (accepted):** `User has answered your questions: "Q1"="A1", "Q2"="A2". You can now...`
|
|
/// **Path D (cancelled):** `User declined to answer...`
|
|
/// **Paths B/C (plan mode):** `- "Q1"\n Answer: A1\n- "Q2"\n (No answer provided)`
|
|
///
|
|
/// Returns `Vec<(question, answer)>`. Empty vec means the output is not a
|
|
/// recognized Q&A format and should be rendered generically.
|
|
fn parse_ask_user_qa_pairs(output: &str) -> Vec<(String, String)> {
|
|
// Path A: "User has answered your questions: "Q"="A", "Q"="A". You can now..."
|
|
if let Some(rest) = output.strip_prefix("User has answered your questions: ") {
|
|
// Strip the trailing ". You can now continue with the user's answers in mind."
|
|
let body = rest
|
|
.strip_suffix(". You can now continue with the user's answers in mind.")
|
|
.unwrap_or(rest);
|
|
|
|
if body.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
// Parse "Q1"="A1", "Q2"="A2" pairs.
|
|
// Split on `", "` that appears between pairs (after `"="value"`).
|
|
let mut pairs = Vec::new();
|
|
let mut remaining = body;
|
|
|
|
while !remaining.is_empty() {
|
|
// Expect: "question"="answer" [optional annotations...]
|
|
if !remaining.starts_with('"') {
|
|
break;
|
|
}
|
|
remaining = &remaining[1..]; // skip opening "
|
|
|
|
// Find the closing " before =
|
|
let Some(q_end) = remaining.find("\"=\"") else {
|
|
break;
|
|
};
|
|
let question = remaining[..q_end].to_string();
|
|
remaining = &remaining[q_end + 3..]; // skip "="
|
|
|
|
// Find the end of the answer: next `", "` pair start or end of string.
|
|
// The answer value continues until we hit `, "` (next pair) or end.
|
|
let answer_end = remaining.find(", \"").unwrap_or(remaining.len());
|
|
|
|
let mut answer_text = remaining[..answer_end].to_string();
|
|
// Strip trailing quote if present (answer is quoted)
|
|
if answer_text.ends_with('"') {
|
|
answer_text.pop();
|
|
}
|
|
|
|
// Remove annotation suffixes (selected preview:..., user notes:...)
|
|
// for display — keep just the label.
|
|
if let Some(ann_start) = answer_text.find(" selected preview:") {
|
|
answer_text.truncate(ann_start);
|
|
}
|
|
if let Some(ann_start) = answer_text.find(" user notes:") {
|
|
answer_text.truncate(ann_start);
|
|
}
|
|
|
|
pairs.push((question, answer_text));
|
|
|
|
// Advance past the separator
|
|
remaining = &remaining[answer_end..];
|
|
if remaining.starts_with(", ") {
|
|
remaining = &remaining[2..];
|
|
}
|
|
}
|
|
|
|
return pairs;
|
|
}
|
|
|
|
// Path D: cancelled
|
|
if output.starts_with("User declined to answer") {
|
|
return vec![]; // No Q&A to show
|
|
}
|
|
|
|
// Paths B/C: plan mode — bullet format
|
|
// - "Q1"\n Answer: A1\n- "Q2"\n (No answer provided)
|
|
if output.contains("Questions asked") && output.contains("- \"") {
|
|
let mut pairs = Vec::new();
|
|
let lines: Vec<&str> = output.lines().collect();
|
|
let mut i = 0;
|
|
while i < lines.len() {
|
|
let line = lines[i].trim_start_matches([' ', '-']).trim();
|
|
// Check for "question text"
|
|
if line.starts_with('"') && line.ends_with('"') {
|
|
let question = line[1..line.len() - 1].to_string();
|
|
let answer = if i + 1 < lines.len() {
|
|
let next = lines[i + 1].trim();
|
|
if let Some(a) = next.strip_prefix("Answer: ") {
|
|
i += 1;
|
|
a.to_string()
|
|
} else if next == "(No answer provided)" {
|
|
i += 1;
|
|
String::new()
|
|
} else {
|
|
String::new()
|
|
}
|
|
} else {
|
|
String::new()
|
|
};
|
|
pairs.push((question, answer));
|
|
}
|
|
i += 1;
|
|
}
|
|
if !pairs.is_empty() {
|
|
return pairs;
|
|
}
|
|
}
|
|
|
|
vec![]
|
|
}
|