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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -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![]
}