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 @@
|
||||
//! Session display-title helpers shared by the dashboard and other surfaces.
|
||||
//!
|
||||
//! Title derivation order ([`entry_title`]):
|
||||
//! 1. `AgentView::display_name` if set (post-rename),
|
||||
//! 2. else `AgentView::generated_session_title` (LLM title or from disk on resume),
|
||||
//! 3. else the trimmed first ~60 chars of the first user-prompt block in scrollback,
|
||||
//! 4. else `"session abc12345"` (or `"loading..."` when no session id
|
||||
//! is established yet).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
|
||||
/// Maximum characters of a derived first-prompt title.
|
||||
const MAX_TITLE_CHARS: usize = 60;
|
||||
|
||||
/// Derive the display title for an agent (rename > generated title > first-prompt > id).
|
||||
///
|
||||
/// Centralised so every surface that shows a session name agrees on the same
|
||||
/// precedence. Trimming and truncation happen in this single place to avoid drift.
|
||||
pub fn entry_title(agent: &AgentView) -> String {
|
||||
if let Some(name) = agent.display_name.as_deref() {
|
||||
let trimmed = name.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return truncate_title(&sanitize_display_text(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(title) = agent.generated_session_title.as_deref() {
|
||||
let trimmed = title.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let clean =
|
||||
kigi_tools::implementations::skills::skill::extract_skill_display_text(trimmed);
|
||||
let text = clean.as_deref().unwrap_or(trimmed);
|
||||
return truncate_title(&sanitize_display_text(text));
|
||||
}
|
||||
}
|
||||
if let Some(text) = first_user_prompt_text(agent) {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let clean =
|
||||
kigi_tools::implementations::skills::skill::extract_skill_display_text(trimmed);
|
||||
let display = clean.as_deref().unwrap_or(trimmed);
|
||||
return truncate_title(&sanitize_display_text(display));
|
||||
}
|
||||
}
|
||||
match agent.session.session_id.as_ref() {
|
||||
Some(sid) => {
|
||||
let short: String = sid.0.chars().take(8).collect();
|
||||
format!("session {short}")
|
||||
}
|
||||
None => "loading...".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the first scrollback `UserPrompt` block's text, if any.
|
||||
///
|
||||
/// Skips indices whose `entry()` returns `None` (defensive: the indexed
|
||||
/// range matches `scrollback.len()` so this should not happen in
|
||||
/// practice) instead of bailing out with `?`, which would conflate
|
||||
/// "no UserPrompt anywhere" with "hit an unexpected gap mid-scan".
|
||||
fn first_user_prompt_text(agent: &AgentView) -> Option<String> {
|
||||
for i in 0..agent.scrollback.len() {
|
||||
if let Some(entry) = agent.scrollback.entry(i)
|
||||
&& let RenderBlock::UserPrompt(block) = &entry.block
|
||||
{
|
||||
return Some(block.text.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Take the first `MAX_TITLE_CHARS` chars and append an ellipsis when
|
||||
/// truncated. Char-based (not byte-based) so multi-byte codepoints
|
||||
/// don't get split.
|
||||
fn truncate_title(text: &str) -> String {
|
||||
if text.chars().count() <= MAX_TITLE_CHARS {
|
||||
return text.to_string();
|
||||
}
|
||||
let head: String = text.chars().take(MAX_TITLE_CHARS).collect();
|
||||
format!("{head}...")
|
||||
}
|
||||
|
||||
/// Strip ASCII control characters (`0x00-0x1f` and `0x7f`) that could
|
||||
/// inject terminal escape sequences (CSI, OSC, BEL, etc.) into the
|
||||
/// rendered output. Replaces stripped chars with `U+FFFD` so the caller
|
||||
/// can still see something was there.
|
||||
///
|
||||
/// Returns `Cow::Borrowed(s)` when no sanitization is needed, so the
|
||||
/// common per-render call on a clean cached display_name does not
|
||||
/// allocate.
|
||||
pub(crate) fn sanitize_display_text(s: &str) -> Cow<'_, str> {
|
||||
if s.chars().any(|c| c.is_ascii_control()) {
|
||||
Cow::Owned(
|
||||
s.chars()
|
||||
.map(|c| if c.is_ascii_control() { '\u{FFFD}' } else { c })
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
Cow::Borrowed(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an elapsed duration as a compact relative label (`now`, `30s ago`,
|
||||
/// `5m ago`, `2h ago`, `3d ago`). Shared by the dashboard and project picker.
|
||||
pub(crate) fn format_relative_time(elapsed: Duration) -> String {
|
||||
let secs = elapsed.as_secs();
|
||||
if secs < 1 {
|
||||
return "now".to_string();
|
||||
}
|
||||
if secs < 60 {
|
||||
return format!("{secs}s ago");
|
||||
}
|
||||
let mins = secs / 60;
|
||||
if mins < 60 {
|
||||
return format!("{mins}m ago");
|
||||
}
|
||||
let hours = mins / 60;
|
||||
if hours < 24 {
|
||||
return format!("{hours}h ago");
|
||||
}
|
||||
let days = hours / 24;
|
||||
format!("{days}d ago")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── sanitize_display_text ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sanitize_passes_through_clean_ascii_unchanged_no_alloc() {
|
||||
let s = "session foo bar";
|
||||
let out = sanitize_display_text(s);
|
||||
assert_eq!(out.as_ref(), s);
|
||||
assert!(matches!(out, Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_passes_through_unicode_widechars() {
|
||||
for s in ["セッション one", "session 🦀 two", "naïve"] {
|
||||
let out = sanitize_display_text(s);
|
||||
assert_eq!(out.as_ref(), s);
|
||||
assert!(matches!(out, Cow::Borrowed(_)), "input={s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_osc_escape_sequence() {
|
||||
// Attack: OSC title-set + clear screen.
|
||||
let attack = "\x1b]0;PWNED\x07\x1b[2J safe text";
|
||||
let out = sanitize_display_text(attack);
|
||||
assert!(matches!(out, Cow::Owned(_)));
|
||||
for c in out.chars() {
|
||||
assert!(!c.is_ascii_control(), "leaked control char: {:?}", c);
|
||||
}
|
||||
// Replacement chars should be present where escapes were.
|
||||
assert!(out.contains('\u{FFFD}'));
|
||||
assert!(out.ends_with(" safe text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_csi_sequence() {
|
||||
let csi = "\x1b[31mred\x1b[0m";
|
||||
let out = sanitize_display_text(csi);
|
||||
for c in out.chars() {
|
||||
assert!(!c.is_ascii_control());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_bel_and_del() {
|
||||
let s = "ring\x07the\x7fbell";
|
||||
let out = sanitize_display_text(s);
|
||||
assert_eq!(out.as_ref(), "ring\u{FFFD}the\u{FFFD}bell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_tab_newline_carriage_return() {
|
||||
// Tabs and newlines are also ASCII controls -- a single-line
|
||||
// rename input should never contain them, so strip all.
|
||||
let s = "a\tb\nc\rd";
|
||||
let out = sanitize_display_text(s);
|
||||
assert_eq!(out.as_ref(), "a\u{FFFD}b\u{FFFD}c\u{FFFD}d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_empty_returns_empty_borrowed() {
|
||||
let out = sanitize_display_text("");
|
||||
assert_eq!(out.as_ref(), "");
|
||||
assert!(matches!(out, Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
// ── truncate_title ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn truncate_title_keeps_short_strings() {
|
||||
assert_eq!(truncate_title("hello"), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_title_appends_ellipsis_when_too_long() {
|
||||
let long = "x".repeat(MAX_TITLE_CHARS + 5);
|
||||
let out = truncate_title(&long);
|
||||
assert!(out.ends_with("..."));
|
||||
assert_eq!(out.chars().count(), MAX_TITLE_CHARS + 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_title_handles_multibyte_codepoints_safely() {
|
||||
// Each "é" (U+00E9) is one char (two bytes); ensure char-based
|
||||
// truncation does not split a multibyte codepoint mid-byte.
|
||||
// This does NOT exercise grapheme-cluster handling -- a
|
||||
// decomposed sequence (e + U+0301) would split at the
|
||||
// codepoint boundary today; that's a separate concern.
|
||||
let s: String = std::iter::repeat_n('é', MAX_TITLE_CHARS + 2).collect();
|
||||
let out = truncate_title(&s);
|
||||
assert!(out.ends_with("..."));
|
||||
assert_eq!(out.chars().count(), MAX_TITLE_CHARS + 3);
|
||||
}
|
||||
|
||||
// ── format_relative_time ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_sub_second_is_now() {
|
||||
assert_eq!(format_relative_time(Duration::from_millis(0)), "now");
|
||||
assert_eq!(format_relative_time(Duration::from_millis(500)), "now");
|
||||
assert_eq!(format_relative_time(Duration::from_millis(999)), "now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_seconds() {
|
||||
assert_eq!(format_relative_time(Duration::from_secs(1)), "1s ago");
|
||||
assert_eq!(format_relative_time(Duration::from_secs(30)), "30s ago");
|
||||
assert_eq!(format_relative_time(Duration::from_secs(59)), "59s ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_minutes() {
|
||||
assert_eq!(format_relative_time(Duration::from_secs(60)), "1m ago");
|
||||
assert_eq!(format_relative_time(Duration::from_secs(120)), "2m ago");
|
||||
assert_eq!(
|
||||
format_relative_time(Duration::from_secs(59 * 60)),
|
||||
"59m ago"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_hours() {
|
||||
assert_eq!(format_relative_time(Duration::from_secs(60 * 60)), "1h ago");
|
||||
assert_eq!(
|
||||
format_relative_time(Duration::from_secs(2 * 60 * 60)),
|
||||
"2h ago"
|
||||
);
|
||||
assert_eq!(
|
||||
format_relative_time(Duration::from_secs(23 * 60 * 60)),
|
||||
"23h ago"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_days() {
|
||||
assert_eq!(
|
||||
format_relative_time(Duration::from_secs(24 * 60 * 60)),
|
||||
"1d ago"
|
||||
);
|
||||
assert_eq!(
|
||||
format_relative_time(Duration::from_secs(3 * 24 * 60 * 60)),
|
||||
"3d ago"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user