Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/agent_view/cta.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

362 lines
17 KiB
Rust

//! Prompt-suggestion gate and follow-up chips: the tab-autocomplete ghost
//! gate plus the follow-up chip lifecycle.
use super::{AgentView, FollowUps, MAX_PENDING_FOLLOW_UPS};
impl AgentView {
/// Refresh the gate for the predicted-next-prompt ghost (tab
/// autocomplete): it only shows on an idle session's normal prompt.
/// Called before key dispatch and before each draw so a turn starting
/// or an input-mode switch hides the ghost immediately. Also re-reads
/// the enabled state so a `/settings` toggle applies live.
pub(crate) fn refresh_prompt_suggestion_gate(&mut self) {
self.prompt.prompt_suggestion.enabled = crate::views::prompt_suggestion::resolve_enabled();
self.prompt.prompt_suggestion_active = self.prompt_input_mode
== super::PromptInputMode::Normal
&& matches!(self.prompt_mode, super::PromptMode::Normal)
&& !self.session.state.is_busy();
}
/// Notify the suggestion controller that the prompt text changed.
/// Returns an Effect to dispatch if the controller wants a debounce.
///
/// Shell suggestions are a bash-mode (`!`) feature: outside it the
/// pipeline never fires (no shell-history ghosts over natural-language
/// chat text) and any leftover ghost/dropdown is torn down.
pub(crate) fn notify_suggestion_text_changed(&mut self) -> Option<super::actions::Effect> {
use crate::views::suggestion_controller::SuggestionAction;
if self.prompt_input_mode != super::PromptInputMode::Bash {
self.prompt.suggestions.clear_ghost();
return None;
}
let snap = self.prompt.slash_state.snapshot();
let slash_active = snap.active;
let has_inline_ghost = snap.inline_ghost.is_some();
// Copy text before passing to text_changed to satisfy the borrow checker.
let text = self.prompt.text().to_owned();
let action = self
.prompt
.suggestions
.text_changed(&text, slash_active, has_inline_ghost)?;
match action {
SuggestionAction::Matched => None,
SuggestionAction::Debounce { generation } => {
Some(super::actions::Effect::DebounceSuggestions {
agent_id: self.session.id,
generation,
})
}
}
}
/// Apply an `x.ai/follow_ups` notification, keyed by `response_id`
/// (newest-response-wins).
///
/// Monotonic accept-the-newer: a never-seen `response_id` is strictly newer
/// than any previously accepted one, so it supersedes the shown chips; a
/// re-delivery of an already-accepted (hence older) response is ignored, so
/// a buffer-replay or duplicate cannot clobber the newest chips on any
/// turn-boundary path, with no reliance on a clear being wired there and no
/// eviction window that could let a stale id pass as new. A re-delivery of
/// the currently-shown response refreshes it in place (no-op when
/// identical); empty `suggestions` retracts that response's chips. Returns
/// `true` when the displayed chips changed (a redraw is warranted).
/// Backward-compatible shim used by tests that don't exercise the turn
/// identity: equivalent to a follow_ups notification with no stamped
/// `promptId` (the older-shell / replay path). Production always routes
/// through [`apply_follow_ups_with_prompt`] from `handle_follow_ups`.
#[cfg(test)]
pub(crate) fn apply_follow_ups(
&mut self,
response_id: String,
suggestions: Vec<String>,
) -> bool {
self.apply_follow_ups_with_prompt(response_id, None, suggestions)
}
/// `apply_follow_ups` with the turn identity (`prompt_id`) the shell stamps
/// on each `x.ai/follow_ups` notification (the same `promptId` it stamps on
/// every `session/update`). The identity makes viewer-adoption dedup
/// DETERMINISTIC:
///
/// - A re-delivery of the CURRENTLY-ADOPTED turn's follow-ups (its
/// `prompt_id` equals `session.current_prompt_id`) re-renders even when its
/// chips were cleared by turn adoption — so chips that were applied then
/// cleared reappear instead of being lost until reload.
/// - A buffer-replayed `x.ai/follow_ups` for a PRIOR turn's `response_id`
/// stays rejected by the seen-ring (its `prompt_id` is not the active one),
/// so stale chips are never revived on the new turn.
///
/// `prompt_id == None` (older shells, or a replay path that lacks it) is
/// treated as "not provably the current turn" → it falls back to the
/// monotonic newest-wins seen-ring and NEVER revives a cleared prior turn.
pub(crate) fn apply_follow_ups_with_prompt(
&mut self,
response_id: String,
prompt_id: Option<&str>,
suggestions: Vec<String>,
) -> bool {
// Re-delivery of the currently-shown response: refresh in place.
if self
.follow_ups
.as_ref()
.is_some_and(|c| c.response_id == response_id)
{
if self
.follow_ups
.as_ref()
.is_some_and(|c| c.suggestions == suggestions)
{
return false;
}
self.follow_up_chips.clear();
self.hovered_follow_up_chip = None;
if suggestions.is_empty() {
// Empty retraction of the currently-shown chips: drop this id
// from the seen-ring so a later NON-empty delivery for the SAME
// response can be re-accepted and re-rendered. Otherwise the id
// (recorded when first accepted) would make the re-delivery hit
// the `follow_up_seen` reject below and never display. This only
// ever affects the currently-shown (newest) id — a genuinely
// older/superseded id is never the shown one, so it never
// reaches this branch and stays rejected (newest-wins intact).
self.follow_up_seen.remove(&response_id);
self.follow_ups = None;
self.follow_up_shown_prompt_id = None;
} else {
self.follow_ups = Some(FollowUps {
response_id,
suggestions,
});
self.follow_up_shown_prompt_id = prompt_id.map(str::to_owned);
}
return true;
}
// Does this notification belong to the turn the client has currently
// adopted? Deterministic when the shell stamped the `promptId`; `false`
// for older shells / replay paths without one (those rely on the
// newest-wins seen-ring below and never revive a prior turn).
let current_prompt_id = self.session.current_prompt_id.as_deref();
let is_current_turn =
matches!((prompt_id, current_prompt_id), (Some(pid), Some(cur)) if pid == cur);
// A stamped `promptId` that names a DIFFERENT turn than the one
// currently adopted: this is a non-current turn's follow_ups (a PRIOR
// turn's late first-time arrival, or a not-yet-adopted turn). It must
// never render — as a re-delivery OR as "newest" — while another turn is
// active, or its chips would appear over the running turn.
//
// Guarded on `current == Some`: a `None` `promptId` (older shells) has
// no turn identity → newest-wins fallback; and `current == None` (e.g. a
// just-finished turn whose trailing follow_ups arrive after
// `current_prompt_id` was cleared) is NOT a mismatch, so those chips
// still render.
let names_other_active_turn =
matches!((prompt_id, current_prompt_id), (Some(pid), Some(cur)) if pid != cur);
if self.follow_up_seen.contains_key(&response_id) {
// Already accepted. Normally this is an older, superseded response →
// reject (newest-wins; a stale prior-turn buffer-replay must NOT
// revive chips). EXCEPTION: if this IS the currently-adopted turn
// (its `prompt_id` matches the active turn) and it carries chips, a
// re-delivery whose chips were cleared by turn adoption must
// re-render — scoped deterministically to the active turn so a prior
// turn is never revived.
if is_current_turn && !suggestions.is_empty() {
self.follow_up_chips.clear();
self.hovered_follow_up_chip = None;
self.follow_ups = Some(FollowUps {
response_id,
suggestions,
});
self.follow_up_shown_prompt_id = prompt_id.map(str::to_owned);
return true;
}
return false;
}
// First-time (never-seen) arrival for a turn that is NOT the active one.
// It must not render NOW (it would draw over the running turn), but it
// may be a not-yet-adopted FUTURE turn whose follow_ups raced ahead of
// the `session/update` that adopts it. Dropping it would lose the chips
// forever if it is the only delivery. Instead BUFFER it keyed by its
// `promptId`; [`flush_pending_follow_ups`] renders it if/when that turn
// becomes current. A genuinely prior turn's `promptId` never becomes
// current again, so its buffered entry is never flushed (no stale
// revival) and is eventually FIFO-evicted by the cap.
if names_other_active_turn {
if let Some(pid) = prompt_id
&& !suggestions.is_empty()
{
self.buffer_pending_follow_ups(pid.to_owned(), response_id, suggestions);
}
return false;
}
// Strictly newer response: supersede the prior chips (already recorded
// in `follow_up_seen` at its own acceptance, so no re-record needed).
let had_chips = self.follow_ups.take().is_some();
self.follow_up_shown_prompt_id = None;
self.follow_up_chips.clear();
self.hovered_follow_up_chip = None;
if suggestions.is_empty() {
// An empty payload for a never-seen response is a no-op retraction
// and is deliberately NOT recorded, so a later non-empty delivery
// for the same response still renders.
return had_chips;
}
self.follow_up_seen
.insert(response_id.clone(), self.follow_up_next_gen);
self.follow_up_next_gen += 1;
self.follow_ups = Some(FollowUps {
response_id,
suggestions,
});
self.follow_up_shown_prompt_id = prompt_id.map(str::to_owned);
true
}
/// Buffer a stamped `x.ai/follow_ups` for a turn that is not yet current,
/// keyed by its `promptId`. A newer delivery for the same `promptId`
/// overwrites the earlier one (keep the latest); the FIFO order list bounds
/// the map to [`MAX_PENDING_FOLLOW_UPS`], evicting only the oldest entry.
fn buffer_pending_follow_ups(
&mut self,
prompt_id: String,
response_id: String,
suggestions: Vec<String>,
) {
let is_new_key = self
.follow_up_pending
.insert(
prompt_id.clone(),
FollowUps {
response_id,
suggestions,
},
)
.is_none();
if is_new_key {
self.follow_up_pending_order.push_back(prompt_id);
if self.follow_up_pending_order.len() > MAX_PENDING_FOLLOW_UPS
&& let Some(evicted) = self.follow_up_pending_order.pop_front()
{
self.follow_up_pending.remove(&evicted);
}
}
}
/// Flush a buffered `x.ai/follow_ups` for `prompt_id` (a turn that has just
/// become current). Renders the chips through [`apply_follow_ups_with_prompt`]
/// — now that `current_prompt_id == prompt_id`, the stamped delivery is
/// accepted as the active turn's. Returns whether chips were rendered. A
/// no-op when nothing is buffered for `prompt_id`. Callers invoke this AFTER
/// setting `current_prompt_id` to `prompt_id` at every turn-adoption site.
pub(crate) fn flush_pending_follow_ups(&mut self, prompt_id: &str) -> bool {
let Some(pending) = self.follow_up_pending.remove(prompt_id) else {
return false;
};
if let Some(pos) = self
.follow_up_pending_order
.iter()
.position(|p| p == prompt_id)
{
self.follow_up_pending_order.remove(pos);
}
self.apply_follow_ups_with_prompt(pending.response_id, Some(prompt_id), pending.suggestions)
}
/// Drop the shown follow-up chips at a turn start (UX: they belong to the
/// previous response). The response stays recorded in `follow_up_seen`, so a
/// stale re-delivery stays rejected; the active turn's own re-delivery still
/// re-renders via the `prompt_id` match in [`apply_follow_ups_with_prompt`],
/// so this is used for BOTH viewer-adoption and self-driven turn starts.
pub(crate) fn clear_follow_ups(&mut self) {
self.follow_ups = None;
self.follow_up_shown_prompt_id = None;
self.follow_up_chips.clear();
self.hovered_follow_up_chip = None;
}
/// Full follow-up reset for a session reload. Unlike [`clear_follow_ups`]
/// (turn boundary — keeps `follow_up_seen` so a stale re-delivery stays
/// rejected), a reload starts a fresh streaming session: follow-ups never
/// persist, so the prior session's seen ids must also be dropped or they
/// would suppress chips streamed after the reload.
pub(crate) fn reset_follow_ups_for_reload(&mut self) {
self.reset_follow_ups_for_reload_preserving(None);
}
/// Reload reset that PRESERVES the running turn's follow-ups for
/// `keep_prompt_id` (the turn the load is about to adopt). On `SessionLoaded`
/// the running turn's `x.ai/follow_ups` arrive on the ext channel DURING
/// `loading_replay`; an unconditional reset would drop them before adoption
/// could re-render them, so the chips would never appear unless the server
/// resent them. The running turn's chips live in ONE of two places at reset
/// time:
///
/// * [`follow_up_pending`](Self::follow_up_pending) — buffered, never
/// displayed (the turn was not current when the chips arrived); OR
/// * [`follow_ups`](Self::follow_ups) — already ON SCREEN, because
/// `current_prompt_id` was unset or already equalled the running turn, so
/// the delivery took the newest-wins / current-turn render path instead
/// of the buffer.
///
/// Both are preserved (the on-screen copy is the live, latest state, so it
/// wins) by re-buffering the survivor into `follow_up_pending` keyed by
/// `keep_prompt_id`; [`adopt_running_prompt`](Self::adopt_running_prompt)
/// then flushes it. All other state — every OTHER turn's buffer, the seen
/// ring, on-screen chips of any other turn — is still cleared, so a reload
/// never leaves stale chips behind. `None` is a full reset (the
/// reconnect-reload finalize path, which has no running turn to adopt).
pub(crate) fn reset_follow_ups_for_reload_preserving(&mut self, keep_prompt_id: Option<&str>) {
// Capture the running turn's follow_ups BEFORE wiping state. Prefer the
// on-screen copy (it rendered, so it is the latest accepted delivery);
// fall back to the pending buffer.
let kept = keep_prompt_id.and_then(|keep| {
let displayed = self
.follow_up_shown_prompt_id
.as_deref()
.filter(|shown| *shown == keep)
.and_then(|_| self.follow_ups.clone());
displayed
.or_else(|| self.follow_up_pending.get(keep).cloned())
.map(|entry| (keep.to_owned(), entry))
});
self.follow_ups = None;
self.follow_up_shown_prompt_id = None;
self.follow_up_chips.clear();
self.hovered_follow_up_chip = None;
self.follow_up_seen.clear();
self.follow_up_next_gen = 0;
self.follow_up_pending.clear();
self.follow_up_pending_order.clear();
if let Some((pid, entry)) = kept {
self.follow_up_pending.insert(pid.clone(), entry);
self.follow_up_pending_order.push_back(pid);
}
}
/// Index of the follow-up chip under a screen position, if any. Used by
/// the mouse handler to submit the clicked suggestion as a literal prompt.
pub(crate) fn follow_up_chip_at(&self, col: u16, row: u16) -> Option<usize> {
self.follow_up_chips
.iter()
.position(|r| r.contains((col, row).into()))
}
/// Update hover highlight for follow-up chips. Returns true if the hover
/// index changed (caller should re-render).
pub(crate) fn set_hovered_follow_up_chip(&mut self, idx: Option<usize>) -> bool {
if self.hovered_follow_up_chip == idx {
return false;
}
self.hovered_follow_up_chip = idx;
true
}
}