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,274 @@
|
||||
//! Permission request selection, follow-up, cancellation, and queue draining.
|
||||
|
||||
use super::modes::set_yolo_mode;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle permission option selection (AllowOnce, AllowAlways, RejectAlways).
|
||||
///
|
||||
/// Pops the front request, sends the response, and handles queue transitions
|
||||
/// (prompt restore on empty, prompt clear on next-front).
|
||||
///
|
||||
/// Special case for [`kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID`]:
|
||||
/// when the user picks the prepended "Yes, and don't ask again for anything"
|
||||
/// option, this dispatcher (a) sends the standard `Selected` response so the
|
||||
/// in-flight request is allowed once (the shell's `map_selected_outcome`
|
||||
/// resolves the id to `PromptOutcome::AllowOnce`), then (b) reuses the
|
||||
/// existing `set_yolo_mode(true)` flow to flip the local YOLO state, drain
|
||||
/// any remaining queued permissions, persist `[ui] permission_mode =
|
||||
/// "always-approve"` to `~/.kigi/config.toml`, and fire the
|
||||
/// `x.ai/yolo_mode_changed` ACP notification. See the option-id constant
|
||||
/// doc-comment for the full client/shell split. Under a managed-policy
|
||||
/// pin step (b) is refused with a toast — the request is still allowed once.
|
||||
pub(super) fn dispatch_permission_select(
|
||||
app: &mut AppView,
|
||||
option_id: acp::PermissionOptionId,
|
||||
) -> 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(perm) = agent.permission_queue.pop_front() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Detect the "enable always-approve mode" id BEFORE moving option_id
|
||||
// into the response. Cheap str compare on the `Arc<str>` interior.
|
||||
let enable_always_approve =
|
||||
option_id.0.as_ref() == kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID;
|
||||
|
||||
// Remember the user's choice (by option kind) so the next prompt's cursor
|
||||
// sticks to it. Allow-flavored choices only — a rejection must not steer a
|
||||
// later prompt's cursor onto a reject row. Also skip the two options that
|
||||
// aren't per-prompt choices:
|
||||
// - the global always-approve (YOLO) option flips global auto-approve, so
|
||||
// there will be no subsequent prompt to land on;
|
||||
// - "allow all edits during this session" is edit-scoped (kind
|
||||
// `AllowAlways`) — letting it stick would steer an unrelated later
|
||||
// prompt onto its "always allow this command" row, escalating scope.
|
||||
let steers_next_cursor = !enable_always_approve
|
||||
&& option_id.0.as_ref() != kigi_workspace::permission::ALLOW_EDITS_SESSION_OPTION_ID;
|
||||
if steers_next_cursor
|
||||
&& let Some(kind) = perm
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| o.option_id == option_id)
|
||||
.map(|o| o.kind)
|
||||
&& matches!(
|
||||
kind,
|
||||
acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways
|
||||
)
|
||||
{
|
||||
crate::appearance::permission_cursor::set_last_used_permission(
|
||||
crate::appearance::permission_cursor::DefaultSelectedPermission::from_kind(&kind),
|
||||
);
|
||||
}
|
||||
|
||||
// Build response meta. MCP and bash flows are mutually exclusive at
|
||||
// the per-request level; check MCP first because it owns the
|
||||
// `allow-always-mcp` option id and the bash branch is the existing
|
||||
// fallback.
|
||||
let meta = if let Some(scope) = perm
|
||||
.mcp_scope
|
||||
.as_ref()
|
||||
.filter(|_| option_id.0.as_ref() == "allow-always-mcp")
|
||||
{
|
||||
let selection = match scope.selected {
|
||||
crate::views::permission_view::McpScope::Tool => {
|
||||
kigi_workspace::permission::McpScopeSelection::Tool {
|
||||
tool_name: scope.tool_name.clone(),
|
||||
}
|
||||
}
|
||||
crate::views::permission_view::McpScope::Server => match &scope.server_prefix {
|
||||
Some(prefix) => kigi_workspace::permission::McpScopeSelection::Server {
|
||||
server: prefix.clone(),
|
||||
},
|
||||
// Defensive: render path should disable Server when no prefix.
|
||||
None => kigi_workspace::permission::McpScopeSelection::Tool {
|
||||
tool_name: scope.tool_name.clone(),
|
||||
},
|
||||
},
|
||||
};
|
||||
serde_json::to_value(selection)
|
||||
.ok()
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
} else if let Some(ref h) = perm.bash_highlights
|
||||
&& perm.bash_selection_count > 0
|
||||
{
|
||||
let parts: Vec<String> = h.highlighted_words[..perm.bash_selection_count].to_vec();
|
||||
serde_json::to_value(kigi_workspace::permission::BashCommandSelectedTerms {
|
||||
command_parts: parts,
|
||||
})
|
||||
.ok()
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(option_id)),
|
||||
)
|
||||
.meta(meta)))
|
||||
.ok();
|
||||
|
||||
// Queue transition: restore prompt if queue is now empty, clear if next-front.
|
||||
resolve_permission_queue_transition(agent);
|
||||
|
||||
// "Enable always-approve" side effect: flip YOLO + persist + notify.
|
||||
// Reuses the existing `set_yolo_mode` pipeline so telemetry, queue
|
||||
// drain, toast, modal refresh, config persistence, and ACP
|
||||
// notification all flow through one well-tested code path.
|
||||
//
|
||||
// Idempotency: if YOLO is already on, the pager auto-approves in
|
||||
// `handle_permission_request` before the panel is shown, so the
|
||||
// user couldn't have selected this option. The `is_yolo()` guard
|
||||
// is defensive — a redundant call would re-emit the toast and a
|
||||
// duplicate `PersistPermissionMode` effect, but is otherwise safe.
|
||||
if enable_always_approve {
|
||||
let already_on = app
|
||||
.agents
|
||||
.get(&id)
|
||||
.map(|a| a.session.is_yolo())
|
||||
.unwrap_or(false);
|
||||
if !already_on {
|
||||
return set_yolo_mode(app, true);
|
||||
}
|
||||
}
|
||||
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Handle permission followup message (RejectOnce with user-typed text).
|
||||
pub(super) fn dispatch_permission_followup(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![];
|
||||
};
|
||||
let Some(perm) = agent.permission_queue.pop_front() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Find the RejectOnce option.
|
||||
let option_id = perm
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| o.kind == acp::PermissionOptionKind::RejectOnce)
|
||||
.map(|o| o.option_id.clone());
|
||||
|
||||
let Some(option_id) = option_id else {
|
||||
// No RejectOnce option — cancel instead.
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Cancelled,
|
||||
)))
|
||||
.ok();
|
||||
resolve_permission_queue_transition(agent);
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Include followup message in meta.
|
||||
let meta = if !text.trim().is_empty() {
|
||||
serde_json::json!({
|
||||
"followup_message": text,
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(option_id)),
|
||||
)
|
||||
.meta(meta)))
|
||||
.ok();
|
||||
|
||||
resolve_permission_queue_transition(agent);
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Handle permission cancel (Ctrl-C / Esc — cancels front request only).
|
||||
pub(super) fn dispatch_permission_cancel(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![];
|
||||
};
|
||||
let Some(perm) = agent.permission_queue.pop_front() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Cancelled,
|
||||
)))
|
||||
.ok();
|
||||
|
||||
resolve_permission_queue_transition(agent);
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Drain all queued permission requests, sending `Cancelled` to each.
|
||||
///
|
||||
/// Called on turn-end and turn-cancel. After draining, restores the stashed
|
||||
/// prompt text (if any). This is distinct from `dispatch_permission_cancel`
|
||||
/// which cancels only the front request.
|
||||
pub(super) fn drain_permission_queue(agent: &mut AgentView) {
|
||||
agent.last_permission_click = None;
|
||||
if agent.permission_queue.is_empty() {
|
||||
return;
|
||||
}
|
||||
for perm in agent.permission_queue.drain(..) {
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Cancelled,
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
// Queue is now empty — restore stashed prompt.
|
||||
if let Some(stashed) = agent.permission_stashed_prompt.take() {
|
||||
agent.prompt.restore(stashed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle queue transition after resolving (select/followup/cancel) the front
|
||||
/// permission request.
|
||||
///
|
||||
/// - Queue now empty → restore stashed prompt text.
|
||||
/// - Queue still has items → clear prompt text (for next followup input)
|
||||
/// and reset next front's focus to Options.
|
||||
pub(crate) fn resolve_permission_queue_transition(agent: &mut AgentView) {
|
||||
agent.last_permission_click = None;
|
||||
if agent.permission_queue.is_empty() {
|
||||
// Restore original prompt.
|
||||
if let Some(stashed) = agent.permission_stashed_prompt.take() {
|
||||
agent.prompt.restore(stashed);
|
||||
}
|
||||
} else {
|
||||
// Clear any followup text from the just-resolved permission so it
|
||||
// doesn't leak into the next permission's UI.
|
||||
agent.prompt.set_text("");
|
||||
// Reset next front's focus to Options.
|
||||
if let Some(next) = agent.permission_queue.front_mut() {
|
||||
next.focus = crate::views::permission_view::PermissionFocus::Options;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user