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,464 @@
|
||||
//! Feedback, remember-note, btw, and recap dispatchers.
|
||||
|
||||
use super::ctx::with_active_agent;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::{AgentView, PromptInputMode};
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::{SessionEvent, ToolCallBlock};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Monotonic counter for correlating async rewrite responses with the modal
|
||||
/// that requested them. Prevents stale results from populating a different
|
||||
/// note's review modal when the user closes and re-opens quickly.
|
||||
static REWRITE_NONCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn next_rewrite_nonce() -> u64 {
|
||||
REWRITE_NONCE.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Enter feedback mode: visual change to prompt bar (teal accent, pencil prefix).
|
||||
/// No side effects — the user types feedback text and presses Enter to send.
|
||||
pub(super) fn dispatch_enter_feedback_mode(app: &mut AppView) -> Vec<Effect> {
|
||||
with_active_agent(app, |agent| {
|
||||
agent.prompt_input_mode = PromptInputMode::Feedback;
|
||||
agent.prompt.set_text("");
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Enter remember mode: visual change to prompt bar (remember accent, `#` prefix).
|
||||
/// No side effects — the user types a memory note and presses Enter to send.
|
||||
pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> {
|
||||
with_active_agent(app, |agent| {
|
||||
agent.prompt_input_mode = PromptInputMode::Remember;
|
||||
agent.prompt.set_text("");
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Send feedback text to the server. Shows a thank-you message immediately
|
||||
/// and fires the HTTP POST as a background effect.
|
||||
pub(super) fn dispatch_send_feedback(app: &mut AppView, text: String) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.prompt_input_mode = PromptInputMode::Normal;
|
||||
agent.prompt.set_text("");
|
||||
// Submitting feedback retires any edit-contextual ephemeral tip.
|
||||
agent.ephemeral_tip.clear_on_submit();
|
||||
|
||||
let trimmed = text.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
agent.scrollback.push_block(RenderBlock::system(
|
||||
"Please provide feedback text.".to_string(),
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system("No active session.".to_string()));
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.scrollback.push_block(RenderBlock::system(
|
||||
"Thanks for the feedback! The Grok Build team is on it.".to_string(),
|
||||
));
|
||||
|
||||
vec![Effect::SendFeedback {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
feedback_text: trimmed,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Send a raw remember note for LLM-powered rewriting via `x.ai/memory/rewrite`.
|
||||
/// Clears remember mode and prompts the LLM to reformat the note with session
|
||||
/// context. Falls back to direct `SaveMemoryNote` when no session is available.
|
||||
pub(super) fn dispatch_send_remember_note(app: &mut AppView, text: String) -> Vec<Effect> {
|
||||
use crate::views::modal::ActiveModal;
|
||||
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.prompt_input_mode = PromptInputMode::Normal;
|
||||
agent.prompt.set_text("");
|
||||
// Submitting a memory note retires any edit-contextual ephemeral tip.
|
||||
agent.ephemeral_tip.clear_on_submit();
|
||||
|
||||
let trimmed = text.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
agent.scrollback.push_block(RenderBlock::system(
|
||||
"Please provide a memory note.".to_string(),
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let cwd = agent.session.cwd.clone();
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
// No session — open modal with raw content only (no LLM rewrite).
|
||||
agent.active_modal = Some(ActiveModal::RememberNoteReview {
|
||||
raw_content: trimmed.clone(),
|
||||
enhanced_content: None, // no session → no LLM rewrite, Tab disabled
|
||||
showing_enhanced: false,
|
||||
scroll: 0,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
cached_lines: None,
|
||||
cwd,
|
||||
agent_id: id,
|
||||
rewrite_nonce: 0, // no rewrite in flight, nonce unused
|
||||
});
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Open modal with raw content, LLM rewrite in flight.
|
||||
let nonce = next_rewrite_nonce();
|
||||
agent.active_modal = Some(ActiveModal::RememberNoteReview {
|
||||
raw_content: trimmed.clone(),
|
||||
enhanced_content: None,
|
||||
showing_enhanced: false,
|
||||
scroll: 0,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
cached_lines: None,
|
||||
cwd: cwd.clone(),
|
||||
agent_id: id,
|
||||
rewrite_nonce: nonce,
|
||||
});
|
||||
|
||||
let context_summary = extract_session_context(agent);
|
||||
|
||||
vec![Effect::RewriteMemoryNote {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
raw_text: trimmed,
|
||||
context_summary,
|
||||
nonce,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Save the currently displayed remember note from the review modal.
|
||||
pub(super) fn dispatch_save_remember_note_from_modal(app: &mut AppView) -> Vec<Effect> {
|
||||
use crate::views::modal::ActiveModal;
|
||||
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let (content, cwd) = if let Some(ActiveModal::RememberNoteReview {
|
||||
ref raw_content,
|
||||
ref enhanced_content,
|
||||
showing_enhanced,
|
||||
ref cwd,
|
||||
..
|
||||
}) = agent.active_modal
|
||||
{
|
||||
let text = if showing_enhanced {
|
||||
enhanced_content.as_deref().unwrap_or(raw_content)
|
||||
} else {
|
||||
raw_content
|
||||
};
|
||||
(text.trim().to_string(), cwd.clone())
|
||||
} else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.active_modal = None;
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system("Saving memory note...".to_string()));
|
||||
|
||||
vec![Effect::SaveMemoryNote {
|
||||
agent_id: id,
|
||||
text: content,
|
||||
cwd,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Extract session context for the LLM memory rewrite request.
|
||||
///
|
||||
/// Walks scrollback in reverse, collecting:
|
||||
/// - Last 5 user prompts
|
||||
/// - File paths from recent tool calls (Read, Edit, ListDir)
|
||||
/// - CWD and git branch
|
||||
fn extract_session_context(agent: &AgentView) -> String {
|
||||
let mut user_prompts: Vec<String> = Vec::new();
|
||||
let mut file_paths: Vec<String> = Vec::new();
|
||||
|
||||
// Walk scrollback entries in reverse to collect recent context.
|
||||
let len = agent.scrollback.len();
|
||||
for i in (0..len).rev() {
|
||||
let Some(entry) = agent.scrollback.entry(i) else {
|
||||
continue;
|
||||
};
|
||||
match &entry.block {
|
||||
RenderBlock::UserPrompt(prompt) => {
|
||||
if user_prompts.len() < 5 {
|
||||
let text = if prompt.text.len() > 200 {
|
||||
let end = prompt
|
||||
.text
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.take_while(|&i| i <= 200)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
format!("{}...", &prompt.text[..end])
|
||||
} else {
|
||||
prompt.text.clone()
|
||||
};
|
||||
user_prompts.push(text);
|
||||
}
|
||||
}
|
||||
RenderBlock::ToolCall(tc) if file_paths.len() < 20 => match tc {
|
||||
ToolCallBlock::Read(b) => {
|
||||
file_paths.push(b.path.clone());
|
||||
}
|
||||
ToolCallBlock::Edit(b) => {
|
||||
file_paths.push(b.path.clone());
|
||||
}
|
||||
ToolCallBlock::ListDir(b) => {
|
||||
file_paths.push(b.path.clone());
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
// Stop early once we have enough context.
|
||||
if user_prompts.len() >= 5 && file_paths.len() >= 20 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
// CWD
|
||||
parts.push(format!("CWD: {}", agent.session.cwd.display()));
|
||||
|
||||
// Git branch
|
||||
if let Some(ref branch) = agent.current_branch {
|
||||
parts.push(format!("Branch: {branch}"));
|
||||
}
|
||||
|
||||
// Recent prompts (chronological order)
|
||||
if !user_prompts.is_empty() {
|
||||
user_prompts.reverse();
|
||||
parts.push("Recent prompts:".to_string());
|
||||
for p in &user_prompts {
|
||||
parts.push(format!("- {p}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Recent file paths (deduplicated, preserving first-seen order)
|
||||
if !file_paths.is_empty() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
file_paths.retain(|p| seen.insert(p.clone()));
|
||||
parts.push("Recent files:".to_string());
|
||||
for p in &file_paths {
|
||||
parts.push(format!("- {p}"));
|
||||
}
|
||||
}
|
||||
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Send a /btw side question. Bypasses the prompt queue — works even while
|
||||
/// the agent is mid-turn. Fires an ACP ext method and shows a loading overlay.
|
||||
pub(super) fn dispatch_send_btw(app: &mut AppView, question: String) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
agent.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.prompt.set_text("");
|
||||
agent.btw_state = Some(crate::views::btw_overlay::BtwOverlayState::Loading {
|
||||
question: question.clone(),
|
||||
});
|
||||
// Prompt keeps focus while the answer is in flight (panel focuses on Done).
|
||||
agent.btw_focused = false;
|
||||
|
||||
vec![Effect::SendBtw {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
question,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Toast when a manual `/recap` produces no summary. Empty sessions get a clear
|
||||
/// empty-state message; anything else (model failure, empty summary, etc.) keeps
|
||||
/// the generic failure toast.
|
||||
pub(crate) fn recap_unavailable_toast(has_user_messages: bool) -> &'static str {
|
||||
if has_user_messages {
|
||||
"Couldn't generate recap"
|
||||
} else {
|
||||
"No messages yet"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether scrollback already has a user prompt. Scans entries (not
|
||||
/// `turn_count`) so it stays correct during `begin_batch`/`end_batch` session
|
||||
/// load, when `push` defers `rebuild_turns` and `turn_count` can stay 0 while
|
||||
/// replayed prompts are already present.
|
||||
pub(crate) fn scrollback_has_user_messages(
|
||||
scrollback: &crate::scrollback::state::ScrollbackState,
|
||||
) -> bool {
|
||||
scrollback
|
||||
.iter_entries()
|
||||
.any(|(_, entry)| entry.block.is_user_prompt())
|
||||
}
|
||||
|
||||
/// Request a session recap. Bypasses the prompt queue — works even while the
|
||||
/// agent is mid-turn. Fires the `x.ai/recap` ext method; the recap arrives
|
||||
/// asynchronously as a `SessionRecap` notification (rendered in scrollback).
|
||||
///
|
||||
/// `auto` is `false` for an explicit `/recap` and `true` for the automatic
|
||||
/// return-from-away recap. For the manual path we clear the prompt and, when
|
||||
/// no session exists yet, surface a toast; the auto path is best-effort and
|
||||
/// silently no-ops without an active session.
|
||||
pub(super) fn dispatch_send_recap(app: &mut AppView, auto: bool) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Shell is authoritative (remote settings / config / env). Skip client requests
|
||||
// entirely when the feature is off so we never hit `x.ai/recap`.
|
||||
if !app.session_recap_available {
|
||||
if !auto {
|
||||
agent.show_toast("Session recap is not enabled");
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
if !auto {
|
||||
agent.show_toast("No active session");
|
||||
}
|
||||
return vec![];
|
||||
};
|
||||
|
||||
if !auto {
|
||||
agent.prompt.set_text("");
|
||||
// Nothing to summarize yet — show a clear empty-state toast instead of
|
||||
// a spinner that ends in "Couldn't generate recap".
|
||||
//
|
||||
// Skip the short-circuit while session replay is still loading (prompts
|
||||
// may not have arrived yet). Prefer an entry scan over `turn_count()`
|
||||
// so mid-batch resume (deferred `rebuild_turns`) still sees history.
|
||||
if !agent.session.loading_replay && !scrollback_has_user_messages(&agent.scrollback) {
|
||||
agent.show_toast(recap_unavailable_toast(false));
|
||||
return vec![];
|
||||
}
|
||||
// Show an immediate loading block with the animated "running" sidebar so
|
||||
// the user has feedback that a recap is being generated. The
|
||||
// `SessionRecap` handler fills this entry in and stops the animation.
|
||||
// Reuse an existing in-flight loading block instead of stacking spinners
|
||||
// when `/recap` is pressed repeatedly.
|
||||
let already_loading = agent.pending_recap_entry.is_some_and(|eid| {
|
||||
agent
|
||||
.scrollback
|
||||
.get_by_id(eid)
|
||||
.is_some_and(|entry| entry.is_running)
|
||||
});
|
||||
if !already_loading {
|
||||
let entry_id =
|
||||
agent
|
||||
.scrollback
|
||||
.push(crate::scrollback::entry::ScrollbackEntry::running(
|
||||
RenderBlock::session_event(SessionEvent::Recap {
|
||||
summary: String::new(),
|
||||
auto: false,
|
||||
}),
|
||||
));
|
||||
agent.pending_recap_entry = Some(entry_id);
|
||||
}
|
||||
} else {
|
||||
// Retry backoff only — do not consume the away period on dispatch.
|
||||
// The shell often no-ops auto recap until ≥3 min since the last main
|
||||
// turn; mark_recap_shown runs when any SessionRecap arrives (auto or
|
||||
// manual `/recap`).
|
||||
app.notification_service
|
||||
.focus_tracker
|
||||
.note_auto_recap_attempt();
|
||||
}
|
||||
|
||||
vec![Effect::SendRecap { session_id, auto }]
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_memory_note_saved(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
result: Result<(), String>,
|
||||
) -> Vec<Effect> {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::system(format!(
|
||||
"Memory saved to {}",
|
||||
crate::util::display_user_grok_path("memory/MEMORY.md")
|
||||
)));
|
||||
}
|
||||
Err(error) => {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::system(format!(
|
||||
"Couldn't save memory note: {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_btw_response(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
result: Result<String, String>,
|
||||
) -> Vec<Effect> {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
use crate::views::btw_overlay::BtwOverlayState;
|
||||
let question = match &agent.btw_state {
|
||||
Some(BtwOverlayState::Loading { question }) => question.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
match result {
|
||||
Ok(response) => {
|
||||
// Answer arrived: show it (until Esc) and focus the panel
|
||||
// so Up/Down scroll it until the user returns to the prompt.
|
||||
agent.btw_state = Some(BtwOverlayState::done(question, response));
|
||||
agent.btw_focused = true;
|
||||
}
|
||||
Err(error) => {
|
||||
// Error stays until Esc; nothing to scroll, keep prompt focus.
|
||||
agent.btw_state = Some(BtwOverlayState::Error { question, error });
|
||||
agent.btw_focused = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
Reference in New Issue
Block a user