Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/dispatch/status.rs
T
ZacharyZhang-NY ea0ce9d15f F3: Kimi inference pipeline + full grok cloud-surface excision
Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
  dialect (thinking-field mapping, model_id stripping, empty-content
  tool-call message fix, stream_options.include_usage), with kimi-cli
  source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
  refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
  x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)

Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
  share command, credit bar: deleted (single local session lane;
  paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
  gate/tier/team/ZDR fields, app/subscription.rs watch loop,
  dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
  chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
  SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
  RPC; Kigi is zero-egress — nothing to share or retain remotely)

Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
  (https://www.kimi.com/code/authorize_device?user_code=..., verified
  against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
  URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
  wiremock fixture URLs into the real browser (root cause of the
  "garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
  kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
2026-07-17 16:05:51 -04:00

324 lines
12 KiB
Rust

//! Session status, privacy, usage, and info dispatchers.
use super::ctx::get_active_agent;
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView};
use crate::notifications::{NotificationEvent, NotificationEventKind};
use crate::scrollback::block::RenderBlock;
/// Show session info: fetch via x.ai/session/info and display in scrollback.
///
/// Produces Effect::ShowSessionInfo which spawns an async ACP ext request.
/// On completion, TaskResult::SessionInfoComplete shows the formatted info.
pub(super) fn dispatch_show_session_info(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(session_id) = agent.session.session_id.clone() else {
// No active session — error should have been caught by slash command,
// but guard here just in case.
return vec![];
};
vec![Effect::ShowSessionInfo {
agent_id: id,
session_id,
show_resolved_model: app.show_resolved_model,
}]
}
/// Scrub an untrusted error string for toast display. Substitutes a
/// generic placeholder when the input exceeds 120 chars or contains
/// control / bidi-override characters (prevents escape-sequence
/// injection and visual spoofing). Full error stays in tracing logs.
pub(super) fn scrub_error_for_toast(error: &str) -> String {
const MAX_TOAST_ERROR_LEN: usize = 120;
if error.len() > MAX_TOAST_ERROR_LEN
|| error
.chars()
.any(crate::render::line_utils::is_unsafe_display_char)
{
"server error (see logs for details)".to_string()
} else {
error.to_string()
}
}
/// Show context info: fetch via x.ai/session/info and display rich breakdown.
///
/// Produces Effect::ShowContextInfo which spawns an async ACP ext request.
/// On completion, TaskResult::ContextInfoComplete shows the formatted info.
pub(super) fn dispatch_show_context_info(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(session_id) = agent.session.session_id.clone() else {
return vec![];
};
vec![Effect::ShowContextInfo {
agent_id: id,
session_id,
}]
}
/// `/usage` — fetch Kimi usage/quota rows and display them inline.
///
/// Produces [`Effect::FetchUsage`], which asks the shell's `x.ai/billing`
/// extension (`GET {base}/usages`); [`handle_usage_fetched`] renders the
/// rows as a system block in scrollback.
pub(super) fn dispatch_show_usage(app: &mut AppView) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
vec![Effect::FetchUsage { agent_id: id }]
}
/// Render the `/usage` result: the fetched quota rows (kimi-cli
/// `usage.py` semantics — label, remaining-quota bar, percent left, reset
/// hint), "No usage data available." for an empty list, or the error.
pub(super) fn handle_usage_fetched(
app: &mut AppView,
agent_id: AgentId,
result: Result<Vec<kigi_shell::extensions::billing::UsageRow>, String>,
) -> Vec<Effect> {
let msg = match &result {
Ok(rows) if rows.is_empty() => "No usage data available.".to_string(),
Ok(rows) => format_usage_rows(rows),
Err(e) => format!("Couldn't fetch usage: {e}"),
};
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent.scrollback.push_block(RenderBlock::system(msg));
}
vec![]
}
/// Width of the remaining-quota bar, matching kimi-cli's usage panel.
const USAGE_BAR_WIDTH: usize = 20;
/// Format usage rows as aligned text lines (kimi-cli `_format_row`
/// parity): `label [bar] N% left (reset hint)`. The percentage is
/// derived from `used`/`limit` only — a row without a positive limit
/// renders as 0% left with an empty bar, exactly like kimi-cli.
fn format_usage_rows(rows: &[kigi_shell::extensions::billing::UsageRow]) -> String {
let label_width = rows
.iter()
.map(|r| r.label.chars().count())
.max()
.unwrap_or(0)
.max(6);
let mut lines = vec!["API Usage".to_string()];
for row in rows {
let ratio = if row.limit <= 0 {
0.0
} else {
(row.limit - row.used).clamp(0, row.limit) as f64 / row.limit as f64
};
let filled = (ratio * USAGE_BAR_WIDTH as f64).round() as usize;
let filled = filled.min(USAGE_BAR_WIDTH);
let bar: String = "\u{2588}".repeat(filled) + &"\u{2591}".repeat(USAGE_BAR_WIDTH - filled);
let mut line = format!(
" {:<width$} [{bar}] {:.0}% left",
row.label,
ratio * 100.0,
width = label_width,
);
if let Some(hint) = &row.reset_hint {
line.push_str(&format!(" ({hint})"));
}
lines.push(line);
}
lines.join("\n")
}
/// Commit a one-line "update available" notice into the active agent's
/// scrollback. Minimal mode has no welcome screen (the full TUI's update
/// surface), so the background update check's result is shown here instead
/// No-op when there is no active agent.
pub(crate) fn commit_minimal_update_notice(app: &mut AppView, latest_version: &str) {
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
agent.scrollback.push_block(RenderBlock::system(format!(
"Update available: v{latest_version} — restart to apply."
)));
}
}
/// `/queue` — commit a read-only list of the queued prompts as a system block.
/// The text is built by [`crate::app::status_blocks::queue_block_text`]; this
/// just resolves the active agent and pushes it. Works in every render mode; the
/// primary inspection surface in minimal, which has no interactive `QueuePane`.
pub(super) fn dispatch_show_queue(app: &mut AppView) -> Vec<Effect> {
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
let text = crate::app::status_blocks::queue_block_text(agent);
agent.scrollback.push_block(RenderBlock::system(text));
}
vec![]
}
/// `/tasks` — commit a read-only list of background tasks, subagents, and
/// scheduled (`/loop`) tasks as a system block. The text is built by
/// [`crate::app::status_blocks::tasks_block_text`]; this just resolves the
/// active agent and pushes it. Works in every render mode; the primary snapshot
/// surface in minimal, which has no interactive `TasksPane`.
pub(super) fn dispatch_show_tasks(app: &mut AppView) -> Vec<Effect> {
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
let text = crate::app::status_blocks::tasks_block_text(agent);
agent.scrollback.push_block(RenderBlock::system(text));
}
vec![]
}
/// Open the hidden `/gboom` easter egg as a modal over the active agent
/// view. Requires a graphics-capable terminal (kitty protocol or iTerm2);
/// otherwise a toast explains why nothing happened. On session-less
/// surfaces (dashboard, welcome) this is a silent no-op.
///
/// Targets the top-level agent view (where the prompt lives), not a
/// focused subagent view: the modal's tick/draw plumbing runs on the
/// top-level view, mirroring the video viewer.
pub(super) fn dispatch_open_gboom(app: &mut AppView) -> Vec<Effect> {
use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol};
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if detect_graphics_protocol() == GraphicsProtocol::None {
agent.show_toast(
"No demons here \u{2014} GBOOM needs a graphics-capable terminal \
(kitty, Ghostty, WezTerm, iTerm2)",
);
return vec![];
}
// Close other media modals: they share the kitty placement id. Drop the
// image viewer's in-flight loader too (its close path clears both —
// a leaked rx would mis-feed the next image viewer's poll loop).
agent.image_viewer = None;
agent.image_load_rx = None;
agent.video_viewer = None;
agent.gboom = Some(crate::gboom::GboomState::new());
vec![]
}
/// Emit a `SessionReady` notification for the given agent.
///
/// Takes `&NotificationService` separately from `&AgentView` to avoid
/// borrow-checker conflicts when `agent` is borrowed from `app.agents`.
pub(super) fn notify_session_ready(
notification_service: &crate::notifications::NotificationService,
agent: &AgentView,
) {
notification_service.notify(NotificationEvent {
kind: NotificationEventKind::SessionReady,
title: "Grok".into(),
body: NotificationEventKind::SessionReady.as_str().into(),
session_id: agent.session.session_id.as_ref().map(|s| s.0.to_string()),
});
}
// TaskResult handlers.
pub(super) fn handle_context_info_complete(
app: &mut AppView,
agent_id: AgentId,
info: Box<kigi_shell::session::SessionInfoResponse>,
) -> Vec<Effect> {
if let Some(agent) = app.agents.get_mut(&agent_id) {
let model = info.data.model.as_deref().unwrap_or("unknown").to_string();
// Take ownership of the snapshot once, hand a clone to the
// agent's running counters, then move the original into the
// scrollback block (which keeps it for theme-reactive
// re-rendering). This still costs one clone but reads as
// "the agent needs a copy" rather than "the block needs a
// copy", which matches the lifetime story.
let snapshot = info.data.context;
agent.apply_full_context_info(snapshot.clone());
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::context_info(
snapshot, model,
));
}
vec![]
}
// Action handlers.
pub(super) fn dispatch_copy_session_id(app: &mut AppView, index: usize) -> Vec<Effect> {
use crate::views::modal::ActiveModal;
// Try agent modal first, then fall back to app fields (welcome screen).
let id = get_active_agent(app)
.and_then(|agent| {
if let Some(ActiveModal::SessionPicker {
entries: Some(ref e),
..
}) = agent.active_modal
{
e.get(index).map(|entry| entry.id.clone())
} else {
None
}
})
.or_else(|| {
app.session_picker_entries
.as_ref()
.and_then(|s| s.get(index))
.map(|e| e.id.clone())
});
if let Some(id) = id {
let r = crate::clipboard::copy_text(&id);
app.show_toast(r.message);
}
vec![]
}
pub(super) fn dispatch_show_release_notes(
app: &mut AppView,
title: String,
content: String,
) -> Vec<Effect> {
match app.active_view {
ActiveView::Agent(id) => {
if let Some(agent) = app.agents.get_mut(&id) {
agent.active_modal = Some(crate::views::modal::ActiveModal::DocViewer {
title,
content,
scroll: 0,
window: crate::views::modal_window::ModalWindowState::new(),
cached_lines: None,
previous_palette: None,
standalone: true,
});
}
}
ActiveView::Welcome => {
app.welcome_doc_viewer = Some(crate::views::modal::ActiveModal::DocViewer {
title,
content,
scroll: 0,
window: crate::views::modal_window::ModalWindowState::new(),
cached_lines: None,
previous_palette: None,
standalone: true,
});
}
_ => {}
}
vec![]
}