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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,377 @@
//! Hero box component — side-by-side logo + menu inside a bordered box.
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, BorderType, Borders, Widget};
use crate::theme::Theme;
use super::WelcomeLayout;
/// Minimum terminal width for the side-by-side hero box layout.
pub(super) const HERO_BOX_MIN_WIDTH: u16 = 90;
/// Vertical padding (rows) between the box border and its inner content.
const V_PAD: u16 = 1;
/// Horizontal inset (cols) between the right column's content and the box
/// border; also the collapsed left-column width when the logo is hidden.
const H_INSET: u16 = 2;
/// Horizontal gap (cols) between the logo and the right column inside the box.
const LOGO_H_PAD: u16 = 3;
const HERO_SUBTITLE: &str = "Thanks for trying Grok Build, give feedback with /feedback!";
use super::{PROMPT_HEIGHT, VERSION_GAP};
/// Rows the "thanks" subtitle occupies. Hidden when the in-box info slot
/// (changelog) is shown, to keep the box compact.
fn subtitle_rows(info_height: u16) -> u16 {
if info_height > 0 { 0 } else { 1 }
}
/// Height of the hero box's right column: version + optional subtitle +
/// optional info block + the gap before the menu + the menu itself.
fn right_col_height(menu_height: u16, info_height: u16) -> u16 {
let info_gap = if info_height > 0 { 1u16 } else { 0 };
// version(1) + subtitle + [info_gap + info] + gap-before-menu(1) + menu
1 + subtitle_rows(info_height) + info_gap + info_height + 1 + menu_height
}
/// Minimum content-area height the hero box needs to render without truncating:
/// the optional error row, the box, a one-row flex gap, and the fixed rows
/// below (tip + prompt + version). The box always shows the full-height logo,
/// so a terminal shorter than this falls back to the stacked layout instead of
/// overflowing.
pub(super) fn min_content_height(
error_height: u16,
menu_height: u16,
tip_height: u16,
info_height: u16,
) -> u16 {
let inner = super::logo::full_logo_line_count().max(right_col_height(menu_height, info_height));
let hero_box_height = 2 + V_PAD * 2 + inner;
let gap_after_error = if error_height > 0 { 1u16 } else { 0 };
gap_after_error + error_height + hero_box_height + 1 + WelcomeLayout::fixed_below(tip_height)
}
/// Width (cols) of the hero box's left (logo) column, including padding.
/// Collapses to a small inset when the logo is hidden.
fn left_col_width() -> u16 {
let logo_width = super::logo::full_logo_visual_width();
if logo_width == 0 {
H_INSET
} else {
logo_width + LOGO_H_PAD.saturating_sub(1) + LOGO_H_PAD
}
}
/// Compute the hero box layout: bordered box with logo left, version + menu right.
///
/// Sizes the in-box info slot here (the fixed `changelog_height`) so the
/// renderer just draws into `hero_info`.
pub(super) fn compute_hero_box(
content_area: Rect,
error_height: u16,
menu_height: u16,
tip_height: u16,
changelog_height: u16,
) -> WelcomeLayout {
let zero = Rect::default();
let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
let fixed_below = WelcomeLayout::fixed_below(tip_height);
// Column widths are height-independent, so derive them once and reuse for
// both the measurement and the rects: `hero_info.width == info_slot_width`,
// i.e. measured == drawn.
let box_width = content_area.width.saturating_sub(6).min(120);
let inner_width = box_width.saturating_sub(2);
let left_col_width = left_col_width();
let right_width = inner_width.saturating_sub(left_col_width);
let info_slot_width = right_width.saturating_sub(H_INSET);
let info_height = changelog_height;
let logo_rows = super::logo::full_logo_line_count();
let info_gap = if info_height > 0 { 1u16 } else { 0 };
let inner_height = logo_rows.max(right_col_height(menu_height, info_height));
let hero_box_height = 2 + V_PAD * 2 + inner_height;
let gap_after_error = if error_height > 0 { 1 } else { 0 };
let fixed_above = gap_after_error + error_height;
// Top padding for vertical centering (use the default menu height so the
// logo position stays constant regardless of picker/focus state).
let default_menu_height = 4u16;
let default_inner = logo_rows.max(right_col_height(default_menu_height, info_height));
let default_hero = 2 + V_PAD * 2 + default_inner;
let remaining = content_area.height.saturating_sub(fixed_above);
let top_pad = remaining
.saturating_sub(default_hero)
.saturating_sub(fixed_below)
/ 3;
// Centering derives top_pad from the default-menu box, but the fit gate
// (min_content_height) sizes for the actual box with no pad. Clamp to the
// real slack so a taller-than-default menu can't push the rows below the
// box off the bottom at the tight boundary.
let top_pad = top_pad.min(
content_area
.height
.saturating_sub(fixed_above + hero_box_height + 1 + fixed_below),
);
let [
_,
_,
error,
hero_box_slot,
_,
tip,
_,
prompt,
_,
version_slot,
] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(gap_after_error),
Constraint::Length(error_height),
Constraint::Length(hero_box_height),
Constraint::Min(1), // flex gap
Constraint::Length(tip_height),
Constraint::Length(tip_gap),
Constraint::Length(PROMPT_HEIGHT),
Constraint::Length(VERSION_GAP),
Constraint::Length(1),
])
.areas(content_area);
// Horizontally center the hero box (`box_width` derived above).
let [_, hero_box, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(box_width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(hero_box_slot);
// Inner area inside the border + v_pad. Widths reuse the values above; only
// x/y come from the laid-out box.
let inner = Rect {
x: hero_box.x + 1,
y: hero_box.y + 1 + V_PAD,
width: inner_width,
height: inner_height,
};
// Left column: balanced padding around the logo; collapses to a small
// inset when the logo is hidden.
let logo_width = super::logo::full_logo_visual_width();
// Logo body leans right; shave a column off the left pad to optically center.
let logo_left_pad = LOGO_H_PAD.saturating_sub(1);
// Logo top-aligned, horizontally centered within left column.
let hero_logo = Rect {
x: inner.x + logo_left_pad,
y: inner.y,
width: logo_width.min(inner.width.saturating_sub(logo_left_pad)),
height: logo_rows.min(inner.height),
};
// Right column: rest of inner width after left column.
let right_x = inner.x + left_col_width;
// Version line at top of right column.
let hero_version = Rect {
x: right_x,
y: inner.y,
width: right_width,
height: 1,
};
// Subtitle line below version — hidden when the info slot is shown.
let hero_subtitle = if subtitle_rows(info_height) > 0 {
Rect {
x: right_x,
y: inner.y + 1,
width: right_width,
height: 1,
}
} else {
zero
};
// Info block (changelog) below version + optional subtitle.
let info_y = inner.y + 1 + subtitle_rows(info_height) + info_gap;
let hero_info = if info_height > 0 {
Rect {
x: right_x,
y: info_y,
width: info_slot_width,
height: info_height,
}
} else {
zero
};
// version + subtitle + info_gap + info + gap-before-menu
let right_header_rows = 1 + subtitle_rows(info_height) + info_gap + info_height + 1;
// Menu below the header rows, left-aligned in right column.
let hero_menu = Rect {
x: right_x,
y: inner.y + right_header_rows,
width: info_slot_width,
height: menu_height.min(inner.height.saturating_sub(right_header_rows)),
};
WelcomeLayout {
logo: zero,
error,
menu: zero,
changelog: zero,
tip,
prompt,
version: version_slot,
hero_box,
hero_logo,
hero_version,
hero_subtitle,
hero_info,
hero_menu,
}
}
/// Changelog content shown in the hero box info slot.
pub(super) struct ChangelogDisplay<'a> {
pub(super) bullets: &'a [String],
pub(super) has_full_notes: bool,
}
/// Hit-test rects produced by [`render_hero_box`].
pub(super) struct HeroBoxRects {
/// Hit-test rect per menu item row (for click/hover).
pub(super) menu_rects: Vec<Rect>,
/// Clickable changelog info block, if drawn.
pub(super) changelog_cta_rect: Option<Rect>,
}
/// Render the bordered hero box with logo left, version + subtitle + menu right.
pub(super) fn render_hero_box(
layout: &WelcomeLayout,
buf: &mut Buffer,
theme: &Theme,
menu_items: &[(&str, &str)],
selected: Option<usize>,
mouse_pos: Option<(u16, u16)>,
changelog: ChangelogDisplay<'_>,
) -> HeroBoxRects {
// Dim the box border toward the background for a softer, dimmer gray.
let border_color = crate::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45)
.unwrap_or(theme.gray_dim);
let border_block = Block::new()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color));
border_block.render(layout.hero_box, buf);
super::logo::render_full_logo(layout.hero_logo, buf, theme);
super::render_version_badge(
layout.hero_version,
buf,
theme,
None,
0,
false,
super::VersionBadgeMode::HeroInline,
);
// Subtitle line below the version.
if layout.hero_subtitle.height > 0 {
let subtitle_style = Style::default().fg(theme.gray);
buf.set_span(
layout.hero_subtitle.x,
layout.hero_subtitle.y,
&Span::styled(HERO_SUBTITLE, subtitle_style),
layout.hero_subtitle.width,
);
}
// In-box info slot: the changelog, always in this same position.
let mut changelog_cta_rect = None;
if layout.hero_info.height > 0 && !changelog.bullets.is_empty() {
changelog_cta_rect = render_hero_changelog(
buf,
theme,
layout.hero_info,
changelog.bullets,
changelog.has_full_notes,
mouse_pos,
);
}
let menu_rects = super::menu::render_menu(
layout.hero_menu,
buf,
theme,
menu_items,
selected,
mouse_pos,
layout.hero_menu.width,
);
HeroBoxRects {
menu_rects,
changelog_cta_rect,
}
}
/// Render the changelog block (header + bullets) in the info slot. When
/// `clickable` (full notes exist), the whole block opens the notes on click and
/// brightens while hovered; returns that clickable rect.
fn render_hero_changelog(
buf: &mut Buffer,
theme: &Theme,
area: Rect,
bullets: &[String],
clickable: bool,
mouse_pos: Option<(u16, u16)>,
) -> Option<Rect> {
if area.width == 0 || area.height == 0 {
return None;
}
let hovered =
clickable && mouse_pos.is_some_and(|(mx, my)| area.contains(Position::new(mx, my)));
let header_style = super::hover_style(
theme,
hovered,
Style::default()
.fg(theme.gray_bright)
.add_modifier(Modifier::DIM),
);
let title = "Changelog";
buf.set_span(
area.x,
area.y,
&Span::styled(title, header_style),
area.width,
);
// Bullets start 2 rows down (header + blank), matching the height budget.
let bullet_style = super::hover_style(theme, hovered, Style::default().fg(theme.gray_bright));
let max_text_width = area.width.saturating_sub(4) as usize; // " • " prefix + pad
for (i, bullet) in bullets.iter().enumerate() {
let row = area.y + 2 + i as u16;
if row >= area.y + area.height {
break;
}
let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width);
let text = format!(" \u{2022} {truncated}");
buf.set_span(area.x, row, &Span::styled(text, bullet_style), area.width);
}
clickable.then_some(area)
}
@@ -0,0 +1,318 @@
//! Logo component — renders the braille art logo.
//!
//! Hidden entirely on legacy Windows consoles: the U+2800 braille block is
//! not covered by the ConHost raster fonts and would render as tofu.
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use crate::render::color::blend_color;
use crate::theme::Theme;
const LOGO: &str = include_str!("../../../assets/logo/logo07.txt");
const LOGO_SMALL: &str = include_str!("../../../assets/logo/logo05.txt");
/// Height at or above which the small logo is shown (below it, no logo).
const SMALL_LOGO_MIN_HEIGHT: u16 = 22;
/// Height at or above which the full logo is shown.
const FULL_LOGO_MIN_HEIGHT: u16 = 26;
fn pick_logo(window_height: u16) -> Option<&'static str> {
pick_logo_for(window_height, logo_hidden())
}
/// Pure tier selection so tests can drive the legacy-console flag directly.
fn pick_logo_for(window_height: u16, hidden: bool) -> Option<&'static str> {
if hidden || window_height < SMALL_LOGO_MIN_HEIGHT {
None
} else if window_height < FULL_LOGO_MIN_HEIGHT {
Some(LOGO_SMALL)
} else {
Some(LOGO)
}
}
/// The braille art has no ASCII stand-in; see the module doc.
fn logo_hidden() -> bool {
crate::glyphs::is_legacy_windows_console()
}
fn non_empty_lines(logo: &str) -> impl Iterator<Item = &str> {
logo.lines().filter(|l| !l.is_empty())
}
fn count_lines(logo: &str) -> u16 {
non_empty_lines(logo).count() as u16
}
fn visual_width(logo: &str) -> u16 {
non_empty_lines(logo)
.map(unicode_width::UnicodeWidthStr::width)
.max()
.unwrap_or(24) as u16
}
/// Animation phase in seconds since the first render. Wall-clock based so the
/// shimmer speed is independent of the frame rate.
fn anim_phase_secs() -> f32 {
use std::sync::OnceLock;
use std::time::Instant;
static START: OnceLock<Instant> = OnceLock::new();
START.get_or_init(Instant::now).elapsed().as_secs_f32()
}
/// Shimmer redraw cadence in frames per second. The sweep is slow, so a few fps
/// looks smooth while sparing the long-lived welcome screen from full-rate
/// repaints.
const SHIMMER_FPS: f32 = 12.0;
/// Quantized shimmer frame for the current wall-clock phase. The welcome screen
/// redraws only when this advances, throttling the animation to ~`SHIMMER_FPS`
/// rather than the full event-loop tick rate. Pinned to 0 when the logo is
/// hidden.
pub fn shimmer_frame() -> u64 {
if logo_hidden() {
return 0;
}
(anim_phase_secs() * SHIMMER_FPS) as u64
}
/// Per-glyph shine opacity in `[0, 1]` at normalized diagonal position `diag`
/// (0 = bottom-left .. 1 = top-right) and animation time `secs`. A raised-cosine
/// band sweeps bottom-left → top-right and parks off-screen between sweeps; a
/// gentle global pulse breathes underneath it. 0 keeps the resting gray, 1 is
/// full bright.
fn shine_opacity(diag: f32, secs: f32) -> f32 {
const BAND: f32 = 0.38; // half-width of the shine band — wider = more gradual falloff
const CYCLE: f32 = 4.0; // seconds per sweep + rest
const SWEEP_FRAC: f32 = 0.32; // portion of the cycle spent sweeping (~1.3s glint, rest idles)
const SHINE: f32 = 0.33; // peak shine strength
const PULSE: f32 = 0.06; // global breathing amount
const PULSE_SECS: f32 = 5.0; // breathing period
let p = (secs % CYCLE) / CYCLE;
let q = (p / SWEEP_FRAC).min(1.0); // parks the band off-screen during the rest
let band_pos = -BAND + q * (1.0 + 2.0 * BAND);
let pulse = PULSE * (0.5 - 0.5 * (std::f32::consts::TAU * secs / PULSE_SECS).cos());
let d = (diag - band_pos).abs();
let shine = if d < BAND {
0.5 * (1.0 + (std::f32::consts::PI * d / BAND).cos())
} else {
0.0
};
(pulse + SHINE * shine).clamp(0.0, 1.0)
}
fn render_into(area: Rect, buf: &mut Buffer, theme: &Theme, logo: &str) {
let lines: Vec<&str> = non_empty_lines(logo).collect();
let rows = lines.len().max(1) as f32;
let cols = lines
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(1)
.max(1) as f32;
let secs = anim_phase_secs();
// Blend each glyph from the resting gray toward the bright text color by its
// shine opacity, so a sheen sweeps across the braille art. Adjacent glyphs
// that land on the same blended color share one Span to hold down the
// per-frame allocation.
let base = theme.gray;
let hilite = theme.text_primary;
let logo_lines: Vec<Line> = lines
.iter()
.enumerate()
.map(|(row, line)| {
let mut spans: Vec<Span> = Vec::new();
let mut run = String::new();
let mut run_color: Option<Color> = None;
for (col, ch) in line.chars().enumerate() {
// Sweep along the bottom-left → top-right diagonal: the
// coordinate grows as col increases and row decreases.
let diag = (col as f32 + (rows - 1.0 - row as f32)) / (cols + rows);
let color = blend_color(base, hilite, shine_opacity(diag, secs)).unwrap_or(base);
if run_color != Some(color) {
if let Some(prev) = run_color {
spans.push(Span::styled(
std::mem::take(&mut run),
Style::default().fg(prev),
));
}
run_color = Some(color);
}
run.push(ch);
}
if let Some(prev) = run_color {
spans.push(Span::styled(run, Style::default().fg(prev)));
}
Line::from(spans).alignment(Alignment::Center)
})
.collect();
Paragraph::new(logo_lines).render(area, buf);
}
pub fn logo_line_count(window_height: u16) -> u16 {
pick_logo(window_height).map_or(0, count_lines)
}
pub fn logo_visual_width(window_height: u16) -> u16 {
pick_logo(window_height).map_or(24, visual_width)
}
pub fn render_logo(area: Rect, buf: &mut Buffer, theme: &Theme, window_height: u16) {
if let Some(logo) = pick_logo(window_height) {
render_into(area, buf, theme, logo);
}
}
/// The hero box always shows the full logo: it is laid out beside the menu, so
/// it fits whenever the box does. These report and render that logo directly,
/// independent of the height-based [`pick_logo`] tiers used by the stacked
/// layout. When [`logo_hidden`], they report 0 and render nothing.
pub fn full_logo_line_count() -> u16 {
full_logo_line_count_for(logo_hidden())
}
fn full_logo_line_count_for(hidden: bool) -> u16 {
if hidden { 0 } else { count_lines(LOGO) }
}
pub fn full_logo_visual_width() -> u16 {
full_logo_visual_width_for(logo_hidden())
}
fn full_logo_visual_width_for(hidden: bool) -> u16 {
if hidden { 0 } else { visual_width(LOGO) }
}
pub fn render_full_logo(area: Rect, buf: &mut Buffer, theme: &Theme) {
if !logo_hidden() {
render_into(area, buf, theme, LOGO);
}
}
/// Line count of the small logo used in minimal's committed welcome card
/// (0 on a legacy Windows console, where the braille art is suppressed).
pub fn compact_logo_line_count() -> u16 {
if logo_hidden() {
0
} else {
count_lines(LOGO_SMALL)
}
}
/// Render the small braille logo (centered) into `area` for minimal's welcome
/// card. No-op when the logo is hidden.
pub fn render_compact_logo(area: Rect, buf: &mut Buffer, theme: &Theme) {
if !logo_hidden() {
render_into(area, buf, theme, LOGO_SMALL);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logo_sizes_by_height() {
assert!(pick_logo_for(SMALL_LOGO_MIN_HEIGHT - 1, false).is_none());
assert_eq!(
pick_logo_for(SMALL_LOGO_MIN_HEIGHT, false),
Some(LOGO_SMALL)
);
assert_eq!(
pick_logo_for(FULL_LOGO_MIN_HEIGHT - 1, false),
Some(LOGO_SMALL)
);
assert_eq!(pick_logo_for(FULL_LOGO_MIN_HEIGHT, false), Some(LOGO));
}
// The braille art has no legacy-safe stand-in, so every height tier must
// collapse to no logo when the legacy-console flag is set.
#[test]
fn logo_hidden_on_legacy_console_at_every_height() {
for h in [0, SMALL_LOGO_MIN_HEIGHT, FULL_LOGO_MIN_HEIGHT, u16::MAX] {
assert!(pick_logo_for(h, true).is_none(), "height {h}");
}
}
#[test]
fn hero_box_always_uses_full_logo() {
// The box renders the full logo regardless of height (it's laid out
// beside the menu), and it's the large variant — never the small one.
assert_eq!(full_logo_line_count_for(false), count_lines(LOGO));
assert_eq!(full_logo_visual_width_for(false), visual_width(LOGO));
assert!(full_logo_line_count_for(false) > count_lines(LOGO_SMALL));
assert!(full_logo_visual_width_for(false) > visual_width(LOGO_SMALL));
}
#[test]
fn full_logo_helpers_collapse_when_hidden() {
assert_eq!(full_logo_line_count_for(true), 0);
assert_eq!(full_logo_visual_width_for(true), 0);
}
#[test]
fn compact_logo_line_count_matches_small_logo_when_visible() {
// The minimal welcome card budgets exactly the small logo's rows. When
// the logo isn't hidden, the count equals the small art's line count and
// is strictly shorter than the full logo.
if !logo_hidden() {
assert_eq!(compact_logo_line_count(), count_lines(LOGO_SMALL));
assert!(compact_logo_line_count() < count_lines(LOGO));
assert!(compact_logo_line_count() > 0);
} else {
assert_eq!(compact_logo_line_count(), 0);
}
}
#[test]
fn shine_opacity_stays_in_unit_range() {
let mut secs = 0.0;
while secs < 10.0 {
for i in 0..=20 {
let diag = i as f32 / 20.0;
let op = shine_opacity(diag, secs);
assert!(
(0.0..=1.0).contains(&op),
"opacity {op} out of range at diag {diag}, secs {secs}"
);
}
secs += 0.13;
}
}
#[test]
fn shine_band_sweeps_across() {
// The brightest point along the diagonal advances left → right as the
// sweep progresses through its active phase.
let brightest = |secs: f32| -> f32 {
(0..=100)
.map(|i| i as f32 / 100.0)
.max_by(|a, b| {
shine_opacity(*a, secs)
.partial_cmp(&shine_opacity(*b, secs))
.unwrap()
})
.unwrap()
};
let early = brightest(0.1);
let mid = brightest(0.4);
let late = brightest(0.7);
assert!(early < mid, "early {early} should precede mid {mid}");
assert!(mid < late, "mid {mid} should precede late {late}");
}
#[test]
fn shine_rests_dim_between_sweeps() {
// During the rest phase the band is parked off-screen, so an interior
// glyph falls back to at most the gentle pulse — never full bright.
let op = shine_opacity(0.5, 6.0); // secs % 4.0 = 2.0 → past SWEEP_FRAC, in the rest phase
assert!(op < 0.2, "resting opacity {op} should stay dim");
}
}
@@ -0,0 +1,137 @@
//! Menu component — renders shortcut key menus.
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use crate::theme::Theme;
use super::logo::logo_visual_width;
/// Render the welcome menu rows as `label … shortcut`, padded within each row.
/// Returns the Rect for each item row (for hit-testing clicks and hover).
pub fn render_menu(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
items: &[(&str, &str)],
selected: Option<usize>,
mouse_pos: Option<(u16, u16)>,
min_width_hint: u16,
) -> Vec<Rect> {
let label_style = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD);
let label_selected_style = Style::default()
.fg(theme.text_primary)
.bg(theme.bg_highlight)
.add_modifier(Modifier::BOLD);
let key_style = Style::default().fg(theme.gray_bright);
let key_selected_style = Style::default()
.fg(theme.gray_bright)
.bg(theme.bg_highlight);
// Width: label + gap + key. Keep a 4-col gap between label and key for
// readability.
let content_min: u16 = items
.iter()
.map(|(key, label)| (key.len() + label.len() + 4) as u16)
.max()
.unwrap_or(0);
let menu_width = logo_visual_width(area.height)
.max(30)
.max(content_min)
.max(min_width_hint);
let [_, menu_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(menu_width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(area);
let mut rects = Vec::with_capacity(items.len());
for (y, (i, (key, label))) in (menu_centered.y..).zip(items.iter().enumerate()) {
if y >= menu_centered.y + menu_centered.height {
break;
}
let is_selected = selected == Some(i);
let key_width = key.len() as u16;
let label_len = label.len() as u16;
let row_rect = Rect {
x: menu_centered.x,
y,
width: menu_centered.width,
height: 1,
};
rects.push(row_rect);
// Fill row background when selected/hovered
if is_selected {
let hover_bg = Style::default().bg(theme.bg_highlight);
for x in menu_centered.x..menu_centered.x + menu_centered.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(hover_bg);
}
}
}
// Label, flush with the left edge of the menu column.
let lstyle = if is_selected {
label_selected_style
} else {
label_style
};
buf.set_span(menu_centered.x, y, &Span::styled(*label, lstyle), label_len);
// Key shortcut flush with the right edge of the menu column.
let kstyle = if is_selected {
key_selected_style
} else {
key_style
};
buf.set_span(
menu_centered.x + menu_centered.width - key_width,
y,
&Span::styled(*key, kstyle),
key_width,
);
// [x] dismiss affordance restyling (for the import row)
if let Some(x_offset) = key.rfind("[x]") {
let key_x_start = menu_centered.x + menu_centered.width - key_width;
let dismiss_start = key_x_start + x_offset as u16;
let dismiss_end = dismiss_start + 3;
let mouse_on_dismiss = mouse_pos
.is_some_and(|(mx, my)| my == y && mx >= dismiss_start && mx < dismiss_end);
let dismiss_color = if mouse_on_dismiss {
theme.text_primary
} else {
theme.gray_bright
};
let dismiss_style = if is_selected {
Style::default()
.fg(dismiss_color)
.bg(theme.bg_highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(dismiss_color)
.add_modifier(Modifier::BOLD)
};
for (offset, ch) in "[x]".chars().enumerate() {
let col = dismiss_start + offset as u16;
if let Some(cell) = buf.cell_mut((col, y)) {
cell.set_char(ch);
cell.set_style(dismiss_style);
}
}
}
}
rects
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
//! Prompt component — renders the welcome screen prompt using PromptWidget.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use crate::views::prompt_widget::{PromptInfo, PromptStyle, PromptWidget};
use super::WelcomePromptFocus;
pub fn prompt_inset(compact: bool) -> u16 {
if compact { 0 } else { 2 }
}
/// Render the welcome prompt using the shared PromptWidget.
/// Returns the cursor position and ownership-bearing post-flush output.
#[allow(clippy::too_many_arguments)]
pub fn render_prompt(
area: Rect,
buf: &mut Buffer,
focus: WelcomePromptFocus,
prompt: &mut PromptWidget,
info: &PromptInfo<'_>,
pad_left: u16,
pad_right: u16,
compact: bool,
) -> (
Option<(u16, u16)>,
Option<crate::terminal::overlay::PostFlush>,
) {
let focused = focus == WelcomePromptFocus::Focused;
let style = PromptStyle {
focused,
show_prefix: true,
vpad_top: 1,
compact,
chrome: true,
chrome_pad_left: pad_left,
chrome_pad_right: pad_right,
placeholder_override: Some("Type a message..."),
..PromptStyle::default()
};
// Inset the prompt area so the selection box border sits over dark background.
// In compact mode, no inset (prompt_inset returns 0) to match session layout.
let inset = prompt_inset(compact);
let inset_area = Rect {
x: area.x + inset,
y: area.y,
width: area.width.saturating_sub(inset * 2),
height: area.height,
};
let result = prompt.draw(buf, inset_area, None, &style, Some(info));
(result.cursor_pos, result.post_flush_escapes.map(Into::into))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
use crossterm::Command;
fn png() -> [u8; 8] {
[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
}
#[test]
fn prompt_post_flush_keeps_ownership_when_plain_bytes_are_appended() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
crate::terminal::overlay::reset_owner();
let _ = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, 71)
.unwrap()
.commit();
let area = Rect::new(0, 0, 80, 3);
let mut buf = Buffer::empty(area);
let mut prompt = PromptWidget::new();
let info = PromptInfo {
model_name: "test",
flags: &[],
multiline: false,
usage_warning: None,
usage_warning_critical: false,
};
let (_, post_flush) = render_prompt(
area,
&mut buf,
WelcomePromptFocus::Focused,
&mut prompt,
&info,
2,
2,
false,
);
let mut post_flush = post_flush.expect("welcome clear");
let mut cursor_bytes = String::new();
let _ = crate::terminal::SetPointerCursor.write_ansi(&mut cursor_bytes);
assert!(!cursor_bytes.is_empty());
post_flush.append_plain(&cursor_bytes);
assert!(post_flush.as_str().contains("a=d"));
assert!(post_flush.as_str().ends_with(cursor_bytes.as_str()));
assert!(
!crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, 71)
.unwrap()
.as_str()
.contains("a=t"),
"constructing welcome output must not commit its clear"
);
let mut emitted = Vec::new();
post_flush.write_to(&mut emitted).unwrap();
assert!(
crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, 71)
.unwrap()
.as_str()
.contains("a=t"),
"writing welcome output must commit its clear"
);
}
}
@@ -0,0 +1,165 @@
//! Top bar component — renders cwd and git info.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use std::path::{Path, PathBuf};
use crate::git_info;
use crate::render::line_utils::truncate_line;
use crate::theme::Theme;
pub fn render_top_bar(area: Rect, buf: &mut Buffer, theme: &Theme) {
let line = truncate_line(location_line(theme), area.width as usize);
let line_width = line.width() as u16;
buf.set_line(area.x, area.y, &line, line_width.min(area.width));
}
/// Build the `{git branch} {worktree} {cwd}` line for the welcome top bar,
/// reading the live process cwd.
pub(crate) fn location_line(theme: &Theme) -> Line<'static> {
location_line_at(theme, &process_cwd())
}
/// As [`location_line`], but for an explicit `cwd`. The dashboard header
/// passes its staged `app.cwd` so the line tracks a `/cd` immediately,
/// before (or even if) `Effect::SetWorkingDir` moves the process cwd.
///
/// Render-safe: reads the per-cwd git cache; never blocks or spawns `git`.
/// The caller width-truncates the returned line.
pub(crate) fn location_line_at(theme: &Theme, cwd: &Path) -> Line<'static> {
let info_style = Style::default().fg(theme.gray);
let info = git_info::cwd_git_info_lazy(cwd);
let mut parts: Vec<Span> = Vec::new();
if let Some(branch) = info.as_ref().and_then(|i| i.branch.as_deref()) {
let icon = git_info::branch_icon();
let git_text = if branch.is_empty() {
format!("{icon} detached")
} else {
format!("{icon} {branch}")
};
let git_style = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::DIM);
parts.push(Span::styled(git_text, git_style));
parts.push(Span::styled(" ", info_style));
}
// Worktree badge — matches the session status bar's `worktree ` marker
// (accent_user) before the path when the cwd is a linked worktree.
if info.as_ref().is_some_and(|i| i.is_worktree) {
parts.push(Span::styled(
"worktree ",
Style::default().fg(theme.accent_user),
));
}
let cwd_display = format_cwd_display(cwd, info.as_ref());
let cwd_style = Style::default().fg(theme.gray_dim);
parts.push(Span::styled(cwd_display, cwd_style));
Line::from(parts)
}
fn process_cwd() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
/// Format the cwd for the welcome top bar / dashboard header: the actual
/// working directory (tilde-collapsed), with a `(worktree of …)` suffix
/// when `info` reports a linked worktree's main repo. Matches the session
/// status bar (the `worktree ` badge itself is painted by [`location_line`]).
///
/// Pure formatting over the per-cwd git probe — never spawns `git`. On a
/// cache miss (`info == None`, e.g. the very first frame) it still shows the
/// raw cwd path with `~` collapsed; the worktree suffix fills in once the
/// probe lands.
fn format_cwd_display(cwd: &Path, info: Option<&git_info::CwdGitInfo>) -> String {
let display = collapse_home(cwd);
let main_repo = info.and_then(|i| i.main_repo.as_deref());
format_cwd_parts(&display, main_repo)
}
/// Pure formatting for the cwd display — no global state, easy to test.
fn format_cwd_parts(display: &str, main_repo: Option<&str>) -> String {
if let Some(main_repo) = main_repo {
format!("{display} (worktree of {main_repo})")
} else {
display.to_string()
}
}
fn collapse_home(dir: &std::path::Path) -> String {
let path = dir.display().to_string();
match git_info::home_dir() {
Some(home) => path
.strip_prefix(&home)
.map(|s| format!("~{s}"))
.unwrap_or(path),
None => path,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_cwd_plain_repo() {
assert_eq!(format_cwd_parts("~/xai", None), "~/xai");
}
/// A linked worktree shows the `(worktree of …)` suffix — matching the
/// session status bar — regardless of the worktree's human label (the
/// label is no longer shown here; the `worktree ` badge stands in for it).
#[test]
fn format_cwd_worktree_shows_main_repo() {
assert_eq!(
format_cwd_parts("~/wt/session-1", Some("~/xai")),
"~/wt/session-1 (worktree of ~/xai)"
);
}
/// The header shows the ACTUAL cwd, not the git repo root: switching
/// into a subdirectory of a repo reflects the subdirectory. (`/work/...`
/// is outside `$HOME`, so `collapse_home` leaves it verbatim.)
#[test]
fn format_cwd_display_shows_subdir_not_repo_root() {
let info = git_info::CwdGitInfo {
branch: Some("main".into()),
is_worktree: false,
main_repo: None,
worktree_label: None,
};
assert_eq!(
format_cwd_display(Path::new("/work/xai/frontend/apps"), Some(&info)),
"/work/xai/frontend/apps",
);
}
/// A worktree subdirectory shows the `(worktree of …)` suffix (matching
/// the session status bar) while still showing the real subdirectory path.
#[test]
fn format_cwd_display_worktree_subdir_shows_main_repo() {
let info = git_info::CwdGitInfo {
branch: Some("kevin/x".into()),
is_worktree: true,
main_repo: Some("~/xai".into()),
worktree_label: Some("location-picker".into()),
};
assert_eq!(
format_cwd_display(Path::new("/work/wt/location-picker/frontend"), Some(&info)),
"/work/wt/location-picker/frontend (worktree of ~/xai)",
);
}
/// On a cache miss (`info == None`) the header still shows the raw cwd.
#[test]
fn format_cwd_display_cache_miss_shows_raw_cwd() {
assert_eq!(
format_cwd_display(Path::new("/work/xai/frontend/apps"), None),
"/work/xai/frontend/apps",
);
}
}