The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
644 lines
22 KiB
Rust
644 lines
22 KiB
Rust
//! Transcript export, block copying, viewer/modal, and input-log dump dispatchers.
|
|
|
|
use super::ctx::with_active_agent;
|
|
use super::session::lifecycle::skip_picker_and_create_session;
|
|
use crate::app::actions::Effect;
|
|
use crate::app::agent::AgentId;
|
|
use crate::app::app_view::{ActiveView, AppView};
|
|
use crate::scrollback::block::{BlockContent, RenderBlock};
|
|
use crate::scrollback::blocks::ToolCallBlock;
|
|
use agent_client_protocol as acp;
|
|
|
|
/// Copy the selected block's content to the system clipboard.
|
|
///
|
|
/// Respects the block's raw/pretty mode for markdown content.
|
|
/// Shows a toast notification on theExtensionsTab
|
|
pub(super) fn dispatch_copy_block_content(app: &mut AppView) {
|
|
with_active_agent(app, |agent| {
|
|
let Some(idx) = agent.scrollback.selected() else {
|
|
return;
|
|
};
|
|
if agent.scrollback.entry_content_hidden_by_group(idx) {
|
|
return;
|
|
}
|
|
let Some(entry) = agent.scrollback.entry(idx) else {
|
|
return;
|
|
};
|
|
|
|
// BgTask blocks: copy stdout from central store
|
|
let text = if let RenderBlock::BgTask(block) = &entry.block {
|
|
let stdout = agent
|
|
.session
|
|
.bg_tasks
|
|
.get(&block.task_id)
|
|
.map(|t| t.stdout.clone())
|
|
.unwrap_or_default();
|
|
if stdout.is_empty() {
|
|
None
|
|
} else {
|
|
Some(stdout)
|
|
}
|
|
} else {
|
|
entry.block.copy_text(entry.raw)
|
|
};
|
|
|
|
if let Some(text) = text
|
|
&& !text.is_empty()
|
|
{
|
|
agent.copy_to_clipboard(&text);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Copy the Nth most recent assistant message to the clipboard.
|
|
pub(super) fn dispatch_copy_assistant_message(app: &mut AppView, n: usize) {
|
|
with_active_agent(app, |agent| {
|
|
// Collect agent messages in reverse order (most recent first).
|
|
let mut agent_messages: Vec<String> = Vec::new();
|
|
for i in (0..agent.scrollback.len()).rev() {
|
|
if let Some(entry) = agent.scrollback.entry(i)
|
|
&& let RenderBlock::AgentMessage(msg) = &entry.block
|
|
{
|
|
agent_messages.push(msg.copy_text(false));
|
|
}
|
|
}
|
|
|
|
if agent_messages.is_empty() {
|
|
agent
|
|
.scrollback
|
|
.push_block(RenderBlock::system("No assistant messages to copy"));
|
|
return;
|
|
}
|
|
|
|
if n > agent_messages.len() {
|
|
agent.scrollback.push_block(RenderBlock::system(format!(
|
|
"Only {} assistant {} available to copy",
|
|
agent_messages.len(),
|
|
if agent_messages.len() == 1 {
|
|
"message"
|
|
} else {
|
|
"messages"
|
|
}
|
|
)));
|
|
return;
|
|
}
|
|
|
|
let text = &agent_messages[n - 1];
|
|
if text.is_empty() {
|
|
agent
|
|
.scrollback
|
|
.push_block(RenderBlock::system("Assistant message is empty"));
|
|
return;
|
|
}
|
|
|
|
let stats = crate::clipboard::clipboard_stats_suffix(text);
|
|
agent
|
|
.scrollback
|
|
.push_block(RenderBlock::system(format!("Copied to clipboard{stats}")));
|
|
agent.copy_to_clipboard(text);
|
|
});
|
|
}
|
|
|
|
/// Dispatch for the `/export` command.
|
|
/// Collects the active (sub)agent's scrollback, renders a clean Markdown transcript,
|
|
/// and either writes it to the (expanded) file or copies it to the clipboard using the
|
|
/// full route (native + tmux + OSC 52) with appropriate feedback.
|
|
pub(super) fn dispatch_export_conversation(
|
|
app: &mut AppView,
|
|
file_path: Option<std::path::PathBuf>,
|
|
) {
|
|
with_active_agent(app, |agent| {
|
|
let blocks: Vec<_> = (0..agent.scrollback.len())
|
|
.filter_map(|i| agent.scrollback.entry(i).map(|e| &e.block))
|
|
.collect();
|
|
|
|
let md = crate::scrollback::export::render_blocks_to_markdown(blocks);
|
|
|
|
if md.is_empty() {
|
|
agent
|
|
.scrollback
|
|
.push_block(RenderBlock::system("No conversation content to export"));
|
|
return;
|
|
}
|
|
|
|
if let Some(p) = file_path {
|
|
// All fs logic (tilde, mkdir, write) lives here (single owner, thin command layer).
|
|
let expanded =
|
|
std::path::PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref());
|
|
if let Some(parent) = expanded.parent()
|
|
&& let Err(e) = std::fs::create_dir_all(parent)
|
|
{
|
|
agent.scrollback.push_block(RenderBlock::system(format!(
|
|
"Failed to create directory: {e}"
|
|
)));
|
|
return;
|
|
}
|
|
match std::fs::write(&expanded, &md) {
|
|
Ok(()) => {
|
|
agent.scrollback.push_block(RenderBlock::system(format!(
|
|
"Conversation exported to {}",
|
|
expanded.display()
|
|
)));
|
|
}
|
|
Err(e) => {
|
|
// Do not blindly re-emit a user-supplied path in the error message
|
|
// (it may contain secrets or PII); the generic failure is sufficient.
|
|
agent
|
|
.scrollback
|
|
.push_block(RenderBlock::system(format!("Failed to write file: {}", e)));
|
|
}
|
|
}
|
|
} else {
|
|
// Clipboard path: stats block (like assistant copy) + route-aware toast
|
|
// (like block content copy / selection). Good UX for a potentially large transcript.
|
|
let stats = crate::clipboard::clipboard_stats_suffix(&md);
|
|
agent.scrollback.push_block(RenderBlock::system(format!(
|
|
"Conversation copied to clipboard{stats}"
|
|
)));
|
|
agent.copy_to_clipboard(&md);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Open the full transcript in `$PAGER`.
|
|
///
|
|
/// **Minimal mode** renders a full-fidelity ANSI transcript — every block
|
|
/// fully expanded (reasoning in full, tool output uncapped, diff colors kept)
|
|
/// — a full layout + syntax-highlight + ANSI-serialization pass over the whole
|
|
/// session. Rendering that inline froze the event loop for seconds on long
|
|
/// sessions ("laggy /transcript"), and the block model is `!Send` (syntect's
|
|
/// resumable highlighter state lives inside markdown blocks), so it can't be
|
|
/// shipped to a worker either. Instead this only ARMS the request; the minimal
|
|
/// render loop builds the transcript **incrementally, a time-budgeted slice
|
|
/// per frame** (`full_view::pump_transcript`, the same time-sliced amortization
|
|
/// pattern other TUIs use for heavy transcript work), then arms `pending_pager_path`
|
|
/// for the event loop's suspend-into-`$PAGER`.
|
|
///
|
|
/// **Other modes** keep the compact markdown export (string concatenation, no
|
|
/// layout or highlighting — cheap enough to stay synchronous).
|
|
pub(crate) fn dispatch_open_transcript_pager(app: &mut AppView) {
|
|
if app.screen_mode.is_minimal() {
|
|
crate::minimal_api::request_minimal_transcript(app);
|
|
return;
|
|
}
|
|
|
|
let mut md = None;
|
|
with_active_agent(app, |agent| {
|
|
let blocks: Vec<_> = (0..agent.scrollback.len())
|
|
.filter_map(|i| agent.scrollback.entry(i).map(|e| &e.block))
|
|
.collect();
|
|
let rendered = crate::scrollback::export::render_blocks_to_markdown(blocks);
|
|
if !rendered.is_empty() {
|
|
md = Some(rendered);
|
|
}
|
|
});
|
|
|
|
let Some(content) = md else {
|
|
with_active_agent(app, |agent| {
|
|
agent.scrollback.push_block(RenderBlock::system(
|
|
"No conversation transcript to view yet",
|
|
));
|
|
});
|
|
return;
|
|
};
|
|
|
|
let path = std::env::temp_dir().join(format!("kigi-transcript-{}.md", uuid::Uuid::new_v4()));
|
|
match std::fs::write(&path, content) {
|
|
Ok(()) => {
|
|
app.pending_pager_path = Some(path);
|
|
app.pending_pager_ansi = false;
|
|
}
|
|
Err(e) => {
|
|
with_active_agent(app, |agent| {
|
|
agent.scrollback.push_block(RenderBlock::system(format!(
|
|
"Failed to write transcript: {e}"
|
|
)));
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Open the fullscreen block viewer for the selected entry.
|
|
/// Falls back to the image viewer only for entries without a normal block viewer.
|
|
pub(super) fn dispatch_open_block_viewer(app: &mut AppView) {
|
|
use crate::views::block_viewer::BlockViewerPane;
|
|
|
|
with_active_agent(app, |agent| {
|
|
let Some(idx) = agent.scrollback.selected() else {
|
|
return;
|
|
};
|
|
let Some(entry) = agent.scrollback.entry(idx) else {
|
|
return;
|
|
};
|
|
|
|
// Block has images/media but terminal can't render pixels — toast and bail.
|
|
let has_media =
|
|
!entry.block.image_references().is_empty() || entry.block.inline_media().is_some();
|
|
if has_media && !crate::terminal::image::detect_graphics_protocol().supports_images() {
|
|
agent.guard_image_support();
|
|
return;
|
|
}
|
|
|
|
if !entry.block.has_normal_fullscreen_viewer() {
|
|
// Video: Enter starts inline playback (no modal).
|
|
if let Some(first_ref) = entry.block.video_references().first() {
|
|
let path = first_ref.path.clone();
|
|
agent.start_inline_video_playback(&path);
|
|
return;
|
|
}
|
|
// Image: Enter opens the file in the OS-native viewer.
|
|
if let Some(first_ref) = entry.block.image_references().first() {
|
|
let path = first_ref.path.clone();
|
|
agent.open_media_natively(&path);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Try to create a normal viewer for the selected block type.
|
|
let viewer = match &entry.block {
|
|
RenderBlock::Thinking(_) | RenderBlock::AgentMessage(_) => {
|
|
BlockViewerPane::for_markdown(entry.id, entry)
|
|
}
|
|
RenderBlock::ToolCall(ToolCallBlock::Execute(_)) => {
|
|
BlockViewerPane::for_execute(entry.id, entry)
|
|
}
|
|
RenderBlock::ToolCall(ToolCallBlock::Edit(_)) => {
|
|
BlockViewerPane::for_edit(entry.id, entry)
|
|
}
|
|
RenderBlock::ToolCall(ToolCallBlock::Read(_)) => {
|
|
BlockViewerPane::for_read(entry.id, entry)
|
|
}
|
|
RenderBlock::ToolCall(ToolCallBlock::Search(_)) => {
|
|
BlockViewerPane::for_grep(entry.id, entry)
|
|
}
|
|
RenderBlock::ToolCall(ToolCallBlock::ListDir(_)) => {
|
|
BlockViewerPane::for_list_dir(entry.id, entry)
|
|
}
|
|
RenderBlock::ToolCall(ToolCallBlock::WebFetch(_)) => {
|
|
BlockViewerPane::for_web_fetch(entry.id, entry)
|
|
}
|
|
RenderBlock::ToolCall(ToolCallBlock::WebSearch(_)) => {
|
|
BlockViewerPane::for_web_search(entry.id, entry)
|
|
}
|
|
RenderBlock::ToolCall(ToolCallBlock::IntegrationSearch(_)) => {
|
|
BlockViewerPane::for_integration_search(entry.id, entry)
|
|
}
|
|
RenderBlock::ToolCall(ToolCallBlock::UseTool(_)) => {
|
|
BlockViewerPane::for_use_tool(entry.id, entry)
|
|
}
|
|
RenderBlock::BgTask(block) => {
|
|
let stdout = agent
|
|
.session
|
|
.bg_tasks
|
|
.get(&block.task_id)
|
|
.map(|t| t.stdout.as_str())
|
|
.unwrap_or("");
|
|
let is_running = agent
|
|
.session
|
|
.bg_tasks
|
|
.get(&block.task_id)
|
|
.is_some_and(|t| t.status == crate::app::agent::BgTaskStatus::Running);
|
|
Some(BlockViewerPane::for_bg_task(
|
|
entry.id,
|
|
&block.task_id,
|
|
stdout,
|
|
is_running,
|
|
))
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
if viewer.is_some() {
|
|
agent.block_viewer = viewer;
|
|
return;
|
|
}
|
|
|
|
// Video: Enter starts inline playback.
|
|
if let Some(first_ref) = entry.block.video_references().first() {
|
|
let path = first_ref.path.clone();
|
|
agent.start_inline_video_playback(&path);
|
|
return;
|
|
}
|
|
// Image: Enter opens the file in the OS-native viewer.
|
|
if let Some(first_ref) = entry.block.image_references().first() {
|
|
let path = first_ref.path.clone();
|
|
agent.open_media_natively(&path);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Fetch-set that populates every Extensions-modal tab. Shared by the manual
|
|
/// open path, the post-CTA-install auth handoff, and the deferred-fetch
|
|
/// session-ready handlers so they can't drift and leave a tab stuck on its
|
|
/// initial `Loading` state.
|
|
pub(super) fn extensions_modal_tab_fetches(
|
|
agent_id: AgentId,
|
|
session_id: acp::SessionId,
|
|
) -> Vec<Effect> {
|
|
vec![
|
|
Effect::FetchHooksList {
|
|
agent_id,
|
|
session_id: session_id.clone(),
|
|
},
|
|
Effect::FetchPluginsList {
|
|
agent_id,
|
|
session_id: session_id.clone(),
|
|
},
|
|
Effect::FetchMcpsList {
|
|
agent_id,
|
|
session_id: session_id.clone(),
|
|
cache: true,
|
|
},
|
|
Effect::FetchSkillsList {
|
|
agent_id,
|
|
session_id,
|
|
},
|
|
]
|
|
}
|
|
|
|
/// Open the hooks/plugins modal on the active agent view and fetch list data.
|
|
pub(super) fn dispatch_open_extensions_modal(
|
|
app: &mut AppView,
|
|
tab: crate::views::extensions_modal::ExtensionsTab,
|
|
) -> Vec<Effect> {
|
|
use crate::views::extensions_modal::ExtensionsModalState;
|
|
|
|
let ActiveView::Agent(id) = app.active_view else {
|
|
return vec![];
|
|
};
|
|
let Some(agent) = app.agents.get_mut(&id) else {
|
|
return vec![];
|
|
};
|
|
|
|
// Mutual exclusivity: close agents modal when opening extensions.
|
|
agent.agents_modal = None;
|
|
let modal = ExtensionsModalState::new(tab);
|
|
agent.extensions_modal = Some(modal);
|
|
|
|
let Some(session_id) = agent.session.session_id.clone() else {
|
|
// Tabs default to Loading; the fetch fires on SessionCreated. With a
|
|
// picker-deferred session nothing else would create one, so do it now.
|
|
agent.pending_extensions_fetch = true;
|
|
return skip_picker_and_create_session(app, id);
|
|
};
|
|
agent.pending_extensions_fetch = false;
|
|
extensions_modal_tab_fetches(id, session_id)
|
|
}
|
|
|
|
/// Open the agents modal, showing all agent definitions.
|
|
pub(super) fn dispatch_open_config_agents_modal(
|
|
app: &mut AppView,
|
|
initial_tab: Option<crate::views::agents_modal::AgentsTab>,
|
|
) -> Vec<Effect> {
|
|
use crate::views::agents_modal::{AgentsModalState, load_agent_toggle};
|
|
|
|
let ActiveView::Agent(id) = app.active_view else {
|
|
return vec![];
|
|
};
|
|
let bundle = app.bundle_state.clone();
|
|
let Some(agent) = app.agents.get_mut(&id) else {
|
|
return vec![];
|
|
};
|
|
|
|
// Mutual exclusivity with extensions_modal
|
|
agent.extensions_modal = None;
|
|
|
|
let cwd = agent.session.cwd.clone();
|
|
let toggle = load_agent_toggle();
|
|
let model_agent_type = agent
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.and_then(|id| agent.session.models.available.get(id))
|
|
.and_then(model_agent_type_from_info);
|
|
let session_id = agent.session.session_id.clone();
|
|
let active_agent = agent.session_agent_name.clone();
|
|
let mut modal = AgentsModalState::new(
|
|
&cwd,
|
|
&toggle,
|
|
&bundle,
|
|
model_agent_type.as_deref(),
|
|
active_agent,
|
|
);
|
|
if let Some(tab) = initial_tab {
|
|
modal.active_tab = tab;
|
|
}
|
|
agent.agents_modal = Some(modal);
|
|
if let Some(session_id) = session_id {
|
|
return vec![Effect::FetchSessionAgentName {
|
|
agent_id: id,
|
|
session_id,
|
|
}];
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
/// `agentType` / `agent_type` from a catalog `ModelInfo` meta blob.
|
|
fn model_agent_type_from_info(info: &agent_client_protocol::ModelInfo) -> Option<String> {
|
|
let meta = info.meta.as_ref()?;
|
|
["agentType", "agent_type"]
|
|
.into_iter()
|
|
.find_map(|key| meta.get(key))
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_owned)
|
|
}
|
|
|
|
/// Copy the selected block's metadata (e.g., command) to clipboard.
|
|
pub(super) fn dispatch_copy_block_meta(app: &mut AppView) {
|
|
with_active_agent(app, |agent| {
|
|
let Some(idx) = agent.scrollback.selected() else {
|
|
return;
|
|
};
|
|
if agent.scrollback.entry_content_hidden_by_group(idx) {
|
|
return;
|
|
}
|
|
let Some(entry) = agent.scrollback.entry(idx) else {
|
|
return;
|
|
};
|
|
if let Some(text) = entry.block.copy_meta()
|
|
&& !text.is_empty()
|
|
{
|
|
agent.copy_to_clipboard(&text);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Dump the input flight recorder to a JSON file for debugging.
|
|
/// See `input_log.rs` module docs for lifecycle/removal instructions.
|
|
pub(super) fn dispatch_dump_input_log(app: &mut AppView) -> Vec<Effect> {
|
|
let ActiveView::Agent(id) = app.active_view else {
|
|
return vec![];
|
|
};
|
|
let Some(agent) = app.agents.get_mut(&id) else {
|
|
return vec![];
|
|
};
|
|
|
|
if agent.input_log.entry_count() == 0 {
|
|
agent.show_toast("No input events recorded yet.");
|
|
return vec![];
|
|
}
|
|
|
|
let time_span_ms = agent.input_log.time_span_ms();
|
|
let entries = agent.input_log.snapshot_entries();
|
|
let entry_count = entries.len();
|
|
let terminal = crate::terminal::terminal_context().diagnostics_snapshot();
|
|
let session_id = agent.session.session_id.as_ref().map(|s| s.0.to_string());
|
|
let pager_version = crate::client_identity::PAGER_CLIENT_VERSION;
|
|
|
|
let now = chrono::Utc::now();
|
|
let dump = crate::input_log::InputDump {
|
|
dumped_at: now.to_rfc3339(),
|
|
session_id: session_id.clone(),
|
|
pager_version,
|
|
terminal,
|
|
active_pane: format!("{:?}", agent.active_pane),
|
|
textarea_cursor: agent.prompt.cursor(),
|
|
textarea_text_len: agent.prompt.text().len(),
|
|
textarea_has_selection: agent.prompt.textarea.selection_range().is_some(),
|
|
entry_count,
|
|
time_span_ms,
|
|
entries,
|
|
};
|
|
|
|
let json = match serde_json::to_string_pretty(&dump) {
|
|
Ok(j) => j,
|
|
Err(e) => {
|
|
agent.show_toast(&format!("Failed to serialize input log: {e}"));
|
|
return vec![];
|
|
}
|
|
};
|
|
|
|
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
|
|
let logs_dir = kigi_home.join("logs");
|
|
let _ = std::fs::create_dir_all(&logs_dir);
|
|
let ts = now.format("%Y%m%d-%H%M%S");
|
|
let path = logs_dir.join(format!("input-debug-{ts}.json"));
|
|
|
|
match std::fs::write(&path, json) {
|
|
Ok(()) => {
|
|
let display_path = path.display();
|
|
agent.show_toast(&format!(
|
|
"Input log ({entry_count} events) → {display_path}"
|
|
));
|
|
crate::unified_log::info(
|
|
&format!("input debug dump: {entry_count} events, {time_span_ms}ms span"),
|
|
session_id.as_deref(),
|
|
None,
|
|
);
|
|
}
|
|
Err(e) => {
|
|
agent.show_toast(&format!("Failed to write input log: {e}"));
|
|
}
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
// TaskResult handlers.
|
|
|
|
pub(super) fn handle_hooks_list_loaded(
|
|
app: &mut AppView,
|
|
agent_id: AgentId,
|
|
result: Result<kigi_hooks_plugins_types::HooksListResponse, String>,
|
|
) -> Vec<Effect> {
|
|
use crate::views::extensions_modal::TabDataState;
|
|
if let Some(agent) = app.agents.get_mut(&agent_id)
|
|
&& let Some(ref mut modal) = agent.extensions_modal
|
|
{
|
|
modal.hooks_data = match result {
|
|
Ok(response) => {
|
|
// Default all groups to collapsed.
|
|
let mut seen = std::collections::HashSet::new();
|
|
for hook in &response.hooks {
|
|
seen.insert(hook.source_dir.clone());
|
|
}
|
|
modal.hooks_collapsed_groups = seen;
|
|
TabDataState::Loaded(response)
|
|
}
|
|
Err(e) => TabDataState::Error(e),
|
|
};
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
pub(super) fn handle_plugins_list_loaded(
|
|
app: &mut AppView,
|
|
agent_id: AgentId,
|
|
result: Result<kigi_hooks_plugins_types::PluginsListResponse, String>,
|
|
) -> Vec<Effect> {
|
|
use crate::views::extensions_modal::TabDataState;
|
|
if let Some(agent) = app.agents.get_mut(&agent_id)
|
|
&& let Some(ref mut modal) = agent.extensions_modal
|
|
{
|
|
modal.plugins_data = match result {
|
|
Ok(response) => {
|
|
modal.seed_plugin_groups_once(&response.plugins);
|
|
TabDataState::Loaded(response)
|
|
}
|
|
Err(e) => TabDataState::Error(e),
|
|
};
|
|
// Clear pending_action so the UI unblocks as soon as the
|
|
// plugins list arrives.
|
|
modal.pending_action = None;
|
|
modal.pending_entry_index = None;
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
pub(super) fn handle_mcp_toggle_done(
|
|
app: &mut AppView,
|
|
agent_id: AgentId,
|
|
result: Result<(), String>,
|
|
) -> Vec<Effect> {
|
|
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
|
return vec![];
|
|
};
|
|
if let Some(ref mut modal) = agent.extensions_modal
|
|
&& let Err(e) = result
|
|
{
|
|
modal.pending_action = None;
|
|
modal.pending_entry_index = None;
|
|
modal.modal_message = Some(crate::views::extensions_modal::ModalMessage::Error(e));
|
|
return vec![];
|
|
}
|
|
let Some(session_id) = agent.session.session_id.clone() else {
|
|
return vec![];
|
|
};
|
|
vec![Effect::FetchMcpsList {
|
|
agent_id,
|
|
session_id,
|
|
cache: false,
|
|
}]
|
|
}
|
|
|
|
pub(super) fn handle_skills_toggle_done(
|
|
app: &mut AppView,
|
|
agent_id: AgentId,
|
|
result: Result<Vec<kigi_tools::implementations::skills::types::SkillInfo>, String>,
|
|
) -> Vec<Effect> {
|
|
use crate::views::extensions_modal::TabDataState;
|
|
if let Some(agent) = app.agents.get_mut(&agent_id)
|
|
&& let Some(ref mut modal) = agent.extensions_modal
|
|
{
|
|
modal.pending_action = None;
|
|
modal.pending_entry_index = None;
|
|
match result {
|
|
Ok(skills) => {
|
|
let len = skills.len();
|
|
modal.skills_data = TabDataState::Loaded(skills);
|
|
if len > 0 && modal.picker_state.selected >= len {
|
|
modal.picker_state.selected = len.saturating_sub(1);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
modal.modal_message = Some(crate::views::extensions_modal::ModalMessage::Error(e));
|
|
}
|
|
}
|
|
}
|
|
// The toggle effect already called kigi/skills/refresh-baseline
|
|
// which triggers the session to reload skills and push an
|
|
// AvailableCommandsUpdate notification with the updated list.
|
|
vec![]
|
|
}
|