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,362 @@
|
||||
//! Transient user feedback: toasts, ephemeral tips, mode-switch banners,
|
||||
//! terminal-size notes, clipboard-copy feedback, and their tick timers.
|
||||
|
||||
use super::{
|
||||
ActivePane, AgentView, CLIPBOARD_TOAST_DEBOUNCE_MS, MODE_BANNER_TOTAL_TICKS, PromptInputMode,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use super::{AgentPane, test_fixtures};
|
||||
use crate::app::actions::Action;
|
||||
use std::time::Instant;
|
||||
|
||||
impl AgentView {
|
||||
/// Show a brief toast message (e.g., "Copied!").
|
||||
///
|
||||
/// Displayed for ~3 seconds (90 ticks at 30fps). Previous transient toast
|
||||
/// is replaced; [`Self::sticky_toast`] is preserved and returns after this
|
||||
/// expires or is dismissed.
|
||||
pub fn show_toast(&mut self, msg: &str) {
|
||||
let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
|
||||
self.toast = Some((msg, 90));
|
||||
}
|
||||
|
||||
/// Show an ephemeral tip in the banner row above the prompt, gated by the
|
||||
/// app-level per-session `seen_counts` map (`AppView::tip_seen_counts`).
|
||||
/// Returns true when the tip was newly shown (and the per-session count
|
||||
/// incremented in place — never persisted to disk).
|
||||
///
|
||||
/// No-op while the row cannot paint (an occluding view — permission,
|
||||
/// question, modal, subagent takeover, fullscreen viewer, `/gboom`, the
|
||||
/// extensions/agents modals, the goal-detail overlay, or an open prompt
|
||||
/// dropdown — a short terminal, the unknown size before the first draw, or a
|
||||
/// pending re-measure right after a resize event) so counts, TTL, and
|
||||
/// telemetry never burn on an invisible tip.
|
||||
pub fn show_ephemeral_tip(
|
||||
&mut self,
|
||||
tip: crate::tips::EphemeralTip,
|
||||
seen_counts: &mut std::collections::HashMap<&'static str, u32>,
|
||||
) -> bool {
|
||||
if !self.ephemeral_tip_renderable(self.last_terminal_size.1) {
|
||||
return false;
|
||||
}
|
||||
self.ephemeral_tip.show(tip, seen_counts)
|
||||
}
|
||||
|
||||
/// Whether the tip row could paint right now (last drawn size). Lets
|
||||
/// app-level triggers skip work that the show gate would refuse anyway.
|
||||
pub(crate) fn ephemeral_tip_can_render(&self) -> bool {
|
||||
self.ephemeral_tip_renderable(self.last_terminal_size.1)
|
||||
}
|
||||
|
||||
/// Single definition of the agent-level eligibility for the clipboard-image
|
||||
/// tip: the tip row can paint, no image chips are already attached, and the
|
||||
/// current model accepts image input.
|
||||
pub(crate) fn clipboard_image_tip_eligible(&self) -> bool {
|
||||
self.ephemeral_tip_can_render()
|
||||
&& self.prompt.images.is_empty()
|
||||
&& self.session.models.current_model_accepts_images()
|
||||
}
|
||||
|
||||
/// One-shot undo-tip show signal from the last `PromptWidget::handle_key`,
|
||||
/// routed as an action so dispatch can reach `app.tip_seen_counts`. Fires
|
||||
/// only on a qualifying wipe (set exclusively on `PromptEvent::Edited`).
|
||||
pub(super) fn take_prompt_tip_signal(&mut self) -> Option<Action> {
|
||||
// Undo (wipe-to-empty) takes precedence; the wipe and the typed-keyword
|
||||
// nudge are mutually exclusive on a single keypress, so one
|
||||
// `Option<Action>` suffices. The plan nudge is suppressed when already
|
||||
// in plan mode (the optimistic read), while the turn is busy, or in a
|
||||
// special prompt input mode (bash/feedback/remember).
|
||||
if self.prompt.take_undo_tip_fire() {
|
||||
return Some(Action::ShowUndoTip);
|
||||
}
|
||||
let in_plan = self.plan_mode_pending.unwrap_or(self.plan_mode_active);
|
||||
if self.prompt.take_plan_nudge_fire()
|
||||
&& !in_plan
|
||||
&& self.session.state.is_idle()
|
||||
&& self.prompt_input_mode == PromptInputMode::Normal
|
||||
{
|
||||
return Some(Action::ShowPlanNudge);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the ephemeral tip needs tick / animation this frame.
|
||||
/// Ambient tips freeze under EVERY occluder (permission ask, modal,
|
||||
/// dropdown): their TTL burns only while the row can paint, so an
|
||||
/// occluder pauses rather than expires them off-screen.
|
||||
pub(crate) fn ephemeral_tip_needs_tick(&self) -> bool {
|
||||
self.ephemeral_tip.is_active()
|
||||
&& (!self.ephemeral_tip.active_is_ambient() || self.ephemeral_tip_can_render())
|
||||
}
|
||||
|
||||
/// Advance tip TTL only when the tip is allowed to tick (see
|
||||
/// [`Self::ephemeral_tip_needs_tick`]).
|
||||
pub(crate) fn tick_ephemeral_tip(&mut self) -> bool {
|
||||
// Word-select tip lifecycle: any prompt divergence since the tip was
|
||||
// shown (typed, pasted, dropped — every edit path funnels into the
|
||||
// prompt text) retires it, and the snapshot drops once the tip is
|
||||
// gone for any reason. A visible tip is always ticking (it arms the
|
||||
// metronome), so the sweep runs within a frame of the edit.
|
||||
if self.ephemeral_tip.current_key() == Some(crate::tips::word_select::WORD_SELECT_TIP_KEY) {
|
||||
if self.word_select_tip_prompt_snapshot.as_deref() != Some(self.prompt.text()) {
|
||||
self.ephemeral_tip
|
||||
.clear(crate::tips::word_select::WORD_SELECT_TIP_KEY);
|
||||
self.word_select_tip_prompt_snapshot = None;
|
||||
return true;
|
||||
}
|
||||
} else if self.word_select_tip_prompt_snapshot.is_some() {
|
||||
self.word_select_tip_prompt_snapshot = None;
|
||||
}
|
||||
if !self.ephemeral_tip_needs_tick() {
|
||||
return false;
|
||||
}
|
||||
self.ephemeral_tip.tick()
|
||||
}
|
||||
|
||||
/// Unified visibility for the ephemeral tip row: no occluding view, a
|
||||
/// tall-enough screen, and no resize since the height was measured. Shared
|
||||
/// by the show gate and the draw path (reserve + paint), so a view opening
|
||||
/// over an already-shown tip also stops the row's reservation until it
|
||||
/// closes.
|
||||
///
|
||||
/// Most occluders leave an edit-contextual tip active with TTL still
|
||||
/// burning (tip may repaint on close). AMBIENT tips freeze under any
|
||||
/// occluder: paint yields **and** [`Self::tick_ephemeral_tip`] freezes TTL
|
||||
/// so a long-lived occluder cannot burn the tip off-screen or keep
|
||||
/// `needs_animation` hot.
|
||||
///
|
||||
/// An occluder is anything that, later in the same frame, keeps the banner
|
||||
/// row from reaching the user. The transient mode-switch banner and the
|
||||
/// inline `/btw` panel are deliberately NOT occluders: the banner owns the
|
||||
/// slot ~2 s while the tip's TTL ticks, and `/btw` has its own layout slot
|
||||
/// above the banner.
|
||||
///
|
||||
/// Drift warning: banner-covering views are also enumerated in two sibling
|
||||
/// hand-maintained lists — the pre-overlay inline-media clear in `draw` and
|
||||
/// the per-frame `frame_occluder_rects` (dropdowns + goal detail). A new
|
||||
/// banner-covering view must be added here too.
|
||||
pub(super) fn ephemeral_tip_renderable(&self, screen_height: u16) -> bool {
|
||||
let occluded = !self.permission_queue.is_empty()
|
||||
|| self.question_view.is_some()
|
||||
|| self.active_modal.is_some()
|
||||
// Subagent fullscreen takeover: draw early-returns into
|
||||
// draw_subagent_fullscreen and never paints the parent banner.
|
||||
|| self.active_subagent.is_some()
|
||||
// Fullscreen viewers render after the banner paints: image/video/
|
||||
// block dim the whole region down to the shortcuts row (banner
|
||||
// included). line_viewer's overlay stops at turn_status.y when a
|
||||
// turn status shows, so it does NOT always cover the banner — kept
|
||||
// anyway as a safe over-refusal (the gate cannot know layout
|
||||
// heights, and a tip during viewer reading is unwanted regardless).
|
||||
|| self.line_viewer.is_some()
|
||||
|| self.image_viewer.is_some()
|
||||
|| self.video_viewer.is_some()
|
||||
|| self.block_viewer.is_some()
|
||||
// /gboom dims the same down-to-shortcuts region as the video viewer.
|
||||
|| self.gboom.is_some()
|
||||
// Extensions/agents modals are centered popups (render_modal_window)
|
||||
// that capture all input and early-return out of draw; distinct
|
||||
// from active_modal. persona_detail only renders atop the agents
|
||||
// modal. A tip could at most peek beside the modal, so refuse.
|
||||
|| self.extensions_modal.is_some()
|
||||
|| self.agents_modal.is_some()
|
||||
// Goal-detail is a vertically-centered overlay painted after the
|
||||
// tip; its box only reaches the banner row for tall/content-rich
|
||||
// goals, but kept unconditional as a safe over-refusal (like the
|
||||
// modals and line_viewer) since a tip during goal reading is
|
||||
// unwanted regardless.
|
||||
|| (self.show_goal_detail && self.goal_state.is_some())
|
||||
// Prompt dropdowns (@/slash/completion/history) render in the
|
||||
// row directly above the prompt — the banner row — clearing it.
|
||||
|| self.prompt.any_dropdown_open();
|
||||
!self.terminal_size_stale && crate::tips::tip_row_renderable(occluded, screen_height)
|
||||
}
|
||||
|
||||
/// Draw-path re-measure: record the size of the rect this view painted
|
||||
/// into, invalidating Kitty image IDs when it changed (terminals clear
|
||||
/// GPU data on resize), and mark the measurement fresh again.
|
||||
///
|
||||
/// Only draw calls this — the rect can be smaller than the terminal
|
||||
/// (dashboard overlay header band/popup, dev tracing split), so a
|
||||
/// resize event must NOT write an extrapolated size here; it flags
|
||||
/// staleness via `note_terminal_resize` instead and the next draw
|
||||
/// re-measures.
|
||||
pub(crate) fn note_terminal_size(&mut self, size: (u16, u16)) {
|
||||
if self.last_terminal_size != (0, 0) && self.last_terminal_size != size {
|
||||
self.inline_media_ids.clear();
|
||||
self.inline_media_iterm_emitted.clear();
|
||||
crate::terminal::overlay::reset_owner();
|
||||
}
|
||||
self.last_terminal_size = size;
|
||||
self.terminal_size_stale = false;
|
||||
}
|
||||
|
||||
/// Event-path resize note: the terminal changed size, so the height in
|
||||
/// `last_terminal_size` no longer describes what this view can paint —
|
||||
/// chrome (dashboard overlay header/popup, dev tracing split) means the
|
||||
/// view's rect is not derivable from the event's full-terminal size.
|
||||
/// The ephemeral-tip show gate refuses until the next draw re-measures;
|
||||
/// resize draws are debounced (`RESIZE_DEBOUNCE`), so that window is a
|
||||
/// frame's worth of events, and a refusal burns nothing.
|
||||
pub(crate) fn note_terminal_resize(&mut self) {
|
||||
self.terminal_size_stale = true;
|
||||
}
|
||||
|
||||
/// Set or clear the sticky status banner (process-wide indicators should
|
||||
/// use [`Self::set_sticky_toast_recursive`] on every agent view).
|
||||
pub fn set_sticky_toast(&mut self, msg: Option<&str>) {
|
||||
self.sticky_toast = msg.map(|m| crate::glyphs::legacy_glyph_fallback(m).into_owned());
|
||||
}
|
||||
|
||||
/// Propagate sticky status to this view and every nested subagent view.
|
||||
pub fn set_sticky_toast_recursive(&mut self, msg: Option<&str>) {
|
||||
self.set_sticky_toast(msg);
|
||||
for child in self.subagent_views.values_mut() {
|
||||
child.set_sticky_toast_recursive(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a toast with an explicit tick duration.
|
||||
pub fn show_toast_ticks(&mut self, msg: &str, ticks: u8) {
|
||||
let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
|
||||
self.toast = Some((msg, ticks));
|
||||
}
|
||||
|
||||
/// Message currently drawn in the toast slot: transient wins while active,
|
||||
/// otherwise sticky status (if any).
|
||||
pub(super) fn active_toast_message(&self) -> Option<&str> {
|
||||
if let Some((ref msg, _)) = self.toast {
|
||||
return Some(msg.as_str());
|
||||
}
|
||||
let sticky = self.sticky_toast.as_deref()?;
|
||||
// The mouse-off banner advertises how to re-enable. `Ctrl+R` only works
|
||||
// from scrollback, so when the prompt is focused show the
|
||||
// `/toggle-mouse-reporting` command instead (it toggles from any pane).
|
||||
// Storage keeps the scrollback form; swap the displayed text here.
|
||||
if sticky == crate::app::MOUSE_OFF_HINT_SCROLLBACK && self.active_pane == ActivePane::Prompt
|
||||
{
|
||||
return Some(crate::app::MOUSE_OFF_HINT_PROMPT);
|
||||
}
|
||||
Some(sticky)
|
||||
}
|
||||
|
||||
/// Show a transient "Switched to mode: ..." banner above the prompt.
|
||||
///
|
||||
/// Triggered on Shift+Tab mode cycles.
|
||||
/// Renders at full visibility for 2 s, then fades out over the final 0.3 s.
|
||||
pub fn show_mode_switch_banner(&mut self, mode_name: &str) {
|
||||
let msg = format!("Switched to mode: {}", mode_name);
|
||||
self.mode_switch_banner = Some((msg, MODE_BANNER_TOTAL_TICKS));
|
||||
}
|
||||
|
||||
/// Tick the mode-switch banner timer. Returns true if redraw needed
|
||||
/// (active or just expired).
|
||||
pub fn tick_mode_banner(&mut self) -> bool {
|
||||
if let Some((_, ref mut remaining)) = self.mode_switch_banner {
|
||||
if *remaining == 0 {
|
||||
self.mode_switch_banner = None;
|
||||
return true;
|
||||
}
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
return true; // redraw to advance fade
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Copy text to clipboard and show the result toast.
|
||||
pub fn copy_to_clipboard(&mut self, text: &str) -> bool {
|
||||
let r = crate::clipboard::copy_text(text);
|
||||
self.show_toast_ticks(r.message, r.ticks);
|
||||
r.success
|
||||
}
|
||||
|
||||
/// Like [`copy_to_clipboard`] but debounces the toast to prevent
|
||||
/// rapid flickering during quick word/line selections.
|
||||
pub(super) fn copy_to_clipboard_debounced(&mut self, text: &str) {
|
||||
let now = Instant::now();
|
||||
let too_soon = self
|
||||
.last_clipboard_toast_at
|
||||
.is_some_and(|t| now.duration_since(t).as_millis() < CLIPBOARD_TOAST_DEBOUNCE_MS);
|
||||
if too_soon {
|
||||
// Still copy, just skip the toast.
|
||||
let _ = crate::clipboard::copy_text(text);
|
||||
return;
|
||||
}
|
||||
self.last_clipboard_toast_at = Some(now);
|
||||
self.copy_to_clipboard(text);
|
||||
}
|
||||
|
||||
/// Returns `true` if the terminal can render pixel images. Shows a
|
||||
/// toast and returns `false` when no graphics protocol is available.
|
||||
pub(crate) fn guard_image_support(&mut self) -> bool {
|
||||
if crate::terminal::image::detect_graphics_protocol().supports_images() {
|
||||
return true;
|
||||
}
|
||||
let msg = match crate::terminal::terminal_context().graphics_protocol_skip_reason() {
|
||||
Some("tmux") => "Inline images disabled within tmux.",
|
||||
_ => "Image rendering not supported in this terminal",
|
||||
};
|
||||
self.show_toast_ticks(msg, 60);
|
||||
false
|
||||
}
|
||||
|
||||
/// Tick the transient toast timer. Call once per animation tick.
|
||||
/// Returns true if the transient toast was removed (needs redraw so a
|
||||
/// sticky banner can reappear).
|
||||
pub fn tick_toast(&mut self) -> bool {
|
||||
if let Some((_, ref mut remaining)) = self.toast {
|
||||
if *remaining == 0 {
|
||||
self.toast = None;
|
||||
return true;
|
||||
}
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Tick the extensions modal's transient result notice. Returns true if it
|
||||
/// just expired (needs a redraw to erase the badge / status line).
|
||||
pub fn tick_extensions_result_notice(&mut self) -> bool {
|
||||
self.extensions_modal
|
||||
.as_mut()
|
||||
.is_some_and(|m| m.tick_result_notice())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mouse_off_banner_tests {
|
||||
use super::test_fixtures::make_running_agent;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mouse_off_banner_key_swaps_with_focused_pane() {
|
||||
let mut view = make_running_agent();
|
||||
view.set_sticky_toast(Some(crate::app::MOUSE_OFF_HINT_SCROLLBACK));
|
||||
|
||||
// Scrollback focus: Ctrl+R works there, so advertise it.
|
||||
view.active_pane = AgentPane::Scrollback;
|
||||
assert_eq!(
|
||||
view.active_toast_message(),
|
||||
Some(crate::app::MOUSE_OFF_HINT_SCROLLBACK)
|
||||
);
|
||||
|
||||
// Prompt focus: the toggle chord is scrollback-only, so advertise the command.
|
||||
view.active_pane = AgentPane::Prompt;
|
||||
assert_eq!(
|
||||
view.active_toast_message(),
|
||||
Some(crate::app::MOUSE_OFF_HINT_PROMPT)
|
||||
);
|
||||
|
||||
// A transient toast still wins over the sticky banner, regardless of pane.
|
||||
view.show_toast("Copied!");
|
||||
assert_eq!(view.active_toast_message(), Some("Copied!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_mouse_sticky_banner_is_not_swapped() {
|
||||
let mut view = make_running_agent();
|
||||
view.set_sticky_toast(Some("Reconnecting"));
|
||||
view.active_pane = AgentPane::Prompt;
|
||||
assert_eq!(view.active_toast_message(), Some("Reconnecting"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user