M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
//! 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!("grok-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 mut modal = ExtensionsModalState::new(tab);
|
||||
modal.session_team_id = app.team_id.clone();
|
||||
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 x.ai/skills/refresh-baseline
|
||||
// which triggers the session to reload skills and push an
|
||||
// AvailableCommandsUpdate notification with the updated list.
|
||||
vec![]
|
||||
}
|
||||
Reference in New Issue
Block a user