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,359 @@
|
||||
//! Wrapper around `alacritty_terminal::Term` for headless terminal emulation.
|
||||
//!
|
||||
//! Provides a simplified interface for feeding PTY output into the terminal
|
||||
//! state machine and reading back screen content as text, styled JSON, or HTML.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use alacritty_terminal::event::{Event, EventListener};
|
||||
use alacritty_terminal::grid::Dimensions;
|
||||
use alacritty_terminal::index::{Column, Line};
|
||||
use alacritty_terminal::term::cell::Flags;
|
||||
use alacritty_terminal::term::{Config, Term, TermMode};
|
||||
use alacritty_terminal::vte::ansi;
|
||||
|
||||
use crate::styled::{self, StyledLine};
|
||||
|
||||
/// Event listener that captures `PtyWrite` events for forwarding back to PTY.
|
||||
///
|
||||
/// When the terminal emulator needs to respond to device status queries (DSR),
|
||||
/// color queries, etc., it emits `Event::PtyWrite`. These MUST be forwarded
|
||||
/// back to the PTY, otherwise programs like vim/tmux will hang waiting for
|
||||
/// a response.
|
||||
#[derive(Clone)]
|
||||
pub struct SessionListener {
|
||||
pty_write_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SessionListener {
|
||||
pub fn new(pty_write_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>) -> Self {
|
||||
Self { pty_write_tx }
|
||||
}
|
||||
}
|
||||
|
||||
impl EventListener for SessionListener {
|
||||
fn send_event(&self, event: Event) {
|
||||
if let Event::PtyWrite(text) = event {
|
||||
let _ = self.pty_write_tx.send(text.into_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for querying screen content.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ScreenOpts {
|
||||
/// Row range (1-indexed, inclusive). None = all rows.
|
||||
pub rows: Option<Range<usize>>,
|
||||
/// Column range (1-indexed, inclusive). None = all columns.
|
||||
pub cols: Option<Range<usize>>,
|
||||
/// If set, replace the character at cursor position with this char.
|
||||
pub cursor_char: Option<char>,
|
||||
/// Include trailing empty lines (default: trim them).
|
||||
pub include_empty: bool,
|
||||
}
|
||||
|
||||
/// Plain text screen output.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ScreenOutput {
|
||||
pub lines: Vec<String>,
|
||||
pub cursor: CursorPosition,
|
||||
pub size: TerminalSize,
|
||||
}
|
||||
|
||||
/// Cursor position (1-indexed).
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize)]
|
||||
pub struct CursorPosition {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
/// Terminal dimensions.
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize)]
|
||||
pub struct TerminalSize {
|
||||
pub cols: usize,
|
||||
pub rows: usize,
|
||||
}
|
||||
|
||||
/// Active terminal modes — tells callers what the running program has enabled.
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize)]
|
||||
pub struct TerminalModes {
|
||||
/// Alternate screen buffer is active (vim, less, htop, etc.).
|
||||
pub alt_screen: bool,
|
||||
/// Bracketed paste mode — input pasted between ESC[200~ / ESC[201~.
|
||||
pub bracketed_paste: bool,
|
||||
/// Application cursor keys (arrow keys send SS3 instead of CSI).
|
||||
pub app_cursor: bool,
|
||||
/// Application keypad mode.
|
||||
pub app_keypad: bool,
|
||||
/// Line-wrap mode (auto-wrap at right margin).
|
||||
pub line_wrap: bool,
|
||||
/// Origin mode (cursor addressing relative to scroll region).
|
||||
pub origin: bool,
|
||||
/// Cursor is visible.
|
||||
pub show_cursor: bool,
|
||||
/// Insert mode.
|
||||
pub insert: bool,
|
||||
/// LF/NL mode (linefeed also does carriage return).
|
||||
pub linefeed_newline: bool,
|
||||
/// Focus in/out reporting enabled.
|
||||
pub focus_in_out: bool,
|
||||
/// Mouse click reporting enabled.
|
||||
pub mouse_reporting: bool,
|
||||
}
|
||||
|
||||
/// A single line from scrollback history.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ScrollbackLine {
|
||||
/// 1-indexed offset from the bottom of scrollback (1 = most recent).
|
||||
pub offset: usize,
|
||||
/// Text content of the line.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A simple `Dimensions` impl for creating a `Term`.
|
||||
struct TermDimensions {
|
||||
columns: usize,
|
||||
screen_lines: usize,
|
||||
}
|
||||
|
||||
impl TermDimensions {
|
||||
fn new(columns: usize, screen_lines: usize) -> Self {
|
||||
Self {
|
||||
columns,
|
||||
screen_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dimensions for TermDimensions {
|
||||
fn total_lines(&self) -> usize {
|
||||
self.screen_lines
|
||||
}
|
||||
|
||||
fn screen_lines(&self) -> usize {
|
||||
self.screen_lines
|
||||
}
|
||||
|
||||
fn columns(&self) -> usize {
|
||||
self.columns
|
||||
}
|
||||
}
|
||||
|
||||
/// Headless terminal emulator wrapping `alacritty_terminal`.
|
||||
pub struct Terminal {
|
||||
term: Term<SessionListener>,
|
||||
parser: ansi::Processor,
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
/// Create a new terminal with the given dimensions.
|
||||
pub fn new(cols: u16, rows: u16, listener: SessionListener) -> Self {
|
||||
let size = TermDimensions::new(cols as usize, rows as usize);
|
||||
let config = Config::default();
|
||||
let term = Term::new(config, &size, listener);
|
||||
let parser = ansi::Processor::new();
|
||||
|
||||
Self { term, parser }
|
||||
}
|
||||
|
||||
/// Feed raw bytes from PTY output into the terminal emulator.
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.parser.advance(&mut self.term, bytes);
|
||||
}
|
||||
|
||||
/// Get the cursor position (1-indexed).
|
||||
pub fn cursor_position(&self) -> CursorPosition {
|
||||
let point = self.term.grid().cursor.point;
|
||||
CursorPosition {
|
||||
row: point.line.0 as usize + 1,
|
||||
col: point.column.0 + 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get terminal dimensions.
|
||||
pub fn size(&self) -> TerminalSize {
|
||||
TerminalSize {
|
||||
cols: self.term.columns(),
|
||||
rows: self.term.screen_lines(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the terminal.
|
||||
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||||
let size = TermDimensions::new(cols as usize, rows as usize);
|
||||
self.term.resize(size);
|
||||
}
|
||||
|
||||
/// Get the active terminal modes.
|
||||
pub fn terminal_modes(&self) -> TerminalModes {
|
||||
let mode = self.term.mode();
|
||||
TerminalModes {
|
||||
alt_screen: mode.contains(TermMode::ALT_SCREEN),
|
||||
bracketed_paste: mode.contains(TermMode::BRACKETED_PASTE),
|
||||
app_cursor: mode.contains(TermMode::APP_CURSOR),
|
||||
app_keypad: mode.contains(TermMode::APP_KEYPAD),
|
||||
line_wrap: mode.contains(TermMode::LINE_WRAP),
|
||||
origin: mode.contains(TermMode::ORIGIN),
|
||||
show_cursor: mode.contains(TermMode::SHOW_CURSOR),
|
||||
insert: mode.contains(TermMode::INSERT),
|
||||
linefeed_newline: mode.contains(TermMode::LINE_FEED_NEW_LINE),
|
||||
focus_in_out: mode.contains(TermMode::FOCUS_IN_OUT),
|
||||
mouse_reporting: mode.intersects(
|
||||
TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of lines in the scrollback buffer.
|
||||
pub fn scrollback_count(&self) -> usize {
|
||||
self.term.grid().history_size()
|
||||
}
|
||||
|
||||
/// Read scrollback lines. `count` limits how many to return (from the
|
||||
/// bottom / most-recent). Returns them in chronological order (oldest first).
|
||||
pub fn scrollback_lines(&self, count: usize) -> Vec<ScrollbackLine> {
|
||||
let grid = self.term.grid();
|
||||
let history = grid.history_size();
|
||||
let n = count.min(history);
|
||||
let num_cols = grid.columns();
|
||||
|
||||
let mut lines = Vec::with_capacity(n);
|
||||
// history lines are at negative indices: Line(-1) is most recent
|
||||
for offset in (1..=n).rev() {
|
||||
let row = &grid[Line(-(offset as i32))];
|
||||
let mut text = String::new();
|
||||
for col_idx in 0..num_cols {
|
||||
let cell = &row[Column(col_idx)];
|
||||
if cell
|
||||
.flags
|
||||
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
text.push(cell.c);
|
||||
if let Some(zw) = cell.zerowidth() {
|
||||
for &c in zw {
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(ScrollbackLine {
|
||||
offset,
|
||||
text: text.trim_end().to_string(),
|
||||
});
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Read screen content as plain text lines.
|
||||
pub fn screen_content(&self, opts: &ScreenOpts) -> ScreenOutput {
|
||||
let grid = self.term.grid();
|
||||
let num_lines = grid.screen_lines();
|
||||
let num_cols = grid.columns();
|
||||
let cursor = self.cursor_position();
|
||||
|
||||
let (row_start, row_end) = resolve_range(&opts.rows, num_lines);
|
||||
let (col_start, col_end) = resolve_range(&opts.cols, num_cols);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for line_idx in row_start..row_end {
|
||||
let row = &grid[Line(line_idx as i32)];
|
||||
let mut text = String::new();
|
||||
|
||||
for col_idx in col_start..col_end {
|
||||
let cell = &row[Column(col_idx)];
|
||||
|
||||
// Skip wide char spacers.
|
||||
if cell
|
||||
.flags
|
||||
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Replace cursor position if requested.
|
||||
let is_cursor = cursor.row == line_idx + 1 && cursor.col == col_idx + 1;
|
||||
match opts.cursor_char {
|
||||
Some(c) if is_cursor => text.push(c),
|
||||
_ => {
|
||||
text.push(cell.c);
|
||||
if let Some(zw) = cell.zerowidth() {
|
||||
for &c in zw {
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(text);
|
||||
}
|
||||
|
||||
// Trim trailing empty lines unless include_empty is set.
|
||||
if !opts.include_empty {
|
||||
while lines.last().is_some_and(|l| l.trim().is_empty()) {
|
||||
lines.pop();
|
||||
}
|
||||
}
|
||||
|
||||
// Right-trim each line.
|
||||
for line in &mut lines {
|
||||
let trimmed = line.trim_end().to_string();
|
||||
*line = trimmed;
|
||||
}
|
||||
|
||||
ScreenOutput {
|
||||
lines,
|
||||
cursor,
|
||||
size: self.size(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read screen content with full style information.
|
||||
pub fn screen_styled(&self, opts: &ScreenOpts) -> Vec<StyledLine> {
|
||||
let grid = self.term.grid();
|
||||
let num_lines = grid.screen_lines();
|
||||
let num_cols = grid.columns();
|
||||
let cursor = self.cursor_position();
|
||||
|
||||
let (row_start, row_end) = resolve_range(&opts.rows, num_lines);
|
||||
let (col_start, col_end) = resolve_range(&opts.cols, num_cols);
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line_idx in row_start..row_end {
|
||||
let row = &grid[Line(line_idx as i32)];
|
||||
let styled_line =
|
||||
styled::extract_styled_line(row, col_start, col_end, line_idx + 1, &cursor, opts);
|
||||
result.push(styled_line);
|
||||
}
|
||||
|
||||
// Trim trailing empty styled lines unless include_empty is set.
|
||||
if !opts.include_empty {
|
||||
while result.last().is_some_and(|l| l.runs.is_empty()) {
|
||||
result.pop();
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Render screen content as HTML.
|
||||
pub fn screen_html(&self, opts: &ScreenOpts) -> String {
|
||||
let styled = self.screen_styled(opts);
|
||||
styled::render_html(&styled, &self.cursor_position(), &self.size())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an optional 1-indexed range to 0-indexed (start, end).
|
||||
fn resolve_range(range: &Option<Range<usize>>, max: usize) -> (usize, usize) {
|
||||
match range {
|
||||
Some(r) => {
|
||||
let start = r.start.saturating_sub(1).min(max);
|
||||
let end = r.end.min(max);
|
||||
(start, end)
|
||||
}
|
||||
None => (0, max),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user