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,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",
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user