docs(comments): rewrite comments across all crates to the guidelines

Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
This commit is contained in:
2026-07-23 16:55:39 -04:00
parent ff0fb56c67
commit a02b555e66
1458 changed files with 10729 additions and 21750 deletions
@@ -22,7 +22,7 @@ use super::render_mermaid::RenderMermaid;
use super::scroll_mode::ScrollMode;
use super::text_selection::TextSelection;
// -- Defaults (asserted in tests to match UiConfig::default()) --------------
// Defaults (asserted in tests to match UiConfig::default())
const COMPACT_DEFAULT: bool = false;
const TIMESTAMPS_DEFAULT: bool = true;
@@ -52,7 +52,7 @@ const SCROLL_LINES_UNSET: u8 = 0;
const SCROLL_LINES_MIN: u8 = 1;
const SCROLL_LINES_MAX: u8 = 10;
// -- Compact mode ------------------------------------------------------------
// Compact mode
thread_local! {
static COMPACT_CURRENT: Cell<bool> = const { Cell::new(COMPACT_DEFAULT) };
@@ -75,13 +75,13 @@ pub fn load() -> bool {
COMPACT_CURRENT.with(|c| c.get())
}
/// Replace cached `compact_mode` (optimistic update or rollback).
/// Replace cached `compact_mode` (optimistic write or rollback).
pub fn set(enabled: bool) {
COMPACT_CURRENT.with(|c| c.set(enabled));
COMPACT_LOADED.with(|l| l.set(true));
}
// -- Timestamps --------------------------------------------------------------
// Timestamps
thread_local! {
static TIMESTAMPS_CURRENT: Cell<bool> = const { Cell::new(TIMESTAMPS_DEFAULT) };
@@ -108,7 +108,7 @@ pub fn set_timestamps(enabled: bool) {
TIMESTAMPS_LOADED.with(|l| l.set(true));
}
// -- Timeline sidebar ----------------------------------------------------------
// Timeline sidebar
thread_local! {
static TIMELINE_CURRENT: Cell<bool> = const { Cell::new(TIMELINE_DEFAULT) };
@@ -135,7 +135,7 @@ pub fn set_show_timeline(enabled: bool) {
TIMELINE_LOADED.with(|l| l.set(true));
}
// -- Simple mode --------------------------------------------------------------
// Simple mode
thread_local! {
static SIMPLE_MODE_CURRENT: Cell<bool> = const { Cell::new(SIMPLE_MODE_DEFAULT) };
@@ -162,7 +162,7 @@ pub fn set_simple_mode(enabled: bool) {
SIMPLE_MODE_LOADED.with(|l| l.set(true));
}
// -- Vim mode (scrollback) ---------------------------------------------------
// Vim mode (scrollback)
thread_local! {
static VIM_MODE_CURRENT: Cell<bool> = const { Cell::new(VIM_MODE_DEFAULT) };
@@ -197,7 +197,7 @@ pub fn set_vim_mode(enabled: bool) {
VIM_MODE_LOADED.with(|l| l.set(true));
}
// -- Show thinking blocks ----------------------------------------------------
// Show thinking blocks
thread_local! {
static SHOW_THINKING_BLOCKS_CURRENT: Cell<bool> =
@@ -228,7 +228,7 @@ pub fn set_show_thinking_blocks(enabled: bool) {
SHOW_THINKING_BLOCKS_LOADED.with(|l| l.set(true));
}
// -- Group tool verbs ---------------------------------------------------------
// Group tool verbs
thread_local! {
static GROUP_TOOL_VERBS_CURRENT: Cell<bool> =
@@ -259,7 +259,7 @@ pub fn set_group_tool_verbs(enabled: bool) {
GROUP_TOOL_VERBS_LOADED.with(|l| l.set(true));
}
// -- Collapsed edit blocks -----------------------------------------------------
// Collapsed edit blocks
thread_local! {
static COLLAPSED_EDIT_BLOCKS_CURRENT: Cell<bool> =
@@ -292,7 +292,7 @@ pub fn set_collapsed_edit_blocks(enabled: bool) {
COLLAPSED_EDIT_BLOCKS_LOADED.with(|l| l.set(true));
}
// -- Prompt suggestions (tab autocomplete) -----------------------------------
// Prompt suggestions (tab autocomplete)
thread_local! {
static PROMPT_SUGGESTIONS_CURRENT: Cell<bool> =
@@ -324,7 +324,7 @@ pub fn set_prompt_suggestions(enabled: bool) {
PROMPT_SUGGESTIONS_LOADED.with(|l| l.set(true));
}
// -- keep_text_selection (`flash` | `hold`) ----------------------------------
// keep_text_selection (`flash` | `hold`)
thread_local! {
static KEEP_TEXT_SELECTION_CURRENT: Cell<TextSelection> =
@@ -350,7 +350,7 @@ pub fn set_keep_text_selection(value: TextSelection) {
KEEP_TEXT_SELECTION_LOADED.with(|l| l.set(true));
}
// -- Scroll speed ------------------------------------------------------------
// Scroll speed
thread_local! {
static SCROLL_SPEED_CURRENT: Cell<u8> = const { Cell::new(SCROLL_SPEED_DEFAULT) };
@@ -383,7 +383,7 @@ pub fn set_scroll_speed(speed: u8) {
SCROLL_SPEED_LOADED.with(|l| l.set(true));
}
// -- Scroll mode (auto | wheel | trackpad) -----------------------------------
// Scroll mode (auto | wheel | trackpad)
thread_local! {
static SCROLL_MODE_CURRENT: Cell<ScrollMode> = const { Cell::new(SCROLL_MODE_DEFAULT) };
@@ -418,7 +418,7 @@ pub fn set_scroll_mode(value: ScrollMode) {
SCROLL_MODE_LOADED.with(|l| l.set(true));
}
// -- Invert scroll ------------------------------------------------------------
// Invert scroll
thread_local! {
static INVERT_SCROLL_CURRENT: Cell<bool> = const { Cell::new(INVERT_SCROLL_DEFAULT) };
@@ -453,7 +453,7 @@ pub fn set_invert_scroll(enabled: bool) {
INVERT_SCROLL_LOADED.with(|l| l.set(true));
}
// -- Scroll lines ------------------------------------------------------------
// Scroll lines
thread_local! {
static SCROLL_LINES_CURRENT: Cell<u8> = const { Cell::new(SCROLL_LINES_UNSET) };
@@ -494,7 +494,7 @@ pub fn set_scroll_lines(lines: u8) {
SCROLL_LINES_LOADED.with(|l| l.set(true));
}
// -- Render mermaid (auto | on | off) ---------------------------------------
// Render mermaid (auto | on | off)
thread_local! {
static RENDER_MERMAID_CURRENT: Cell<RenderMermaid> = const { Cell::new(RenderMermaid::Auto) };
@@ -531,13 +531,13 @@ fn render_mermaid_from_config_str(value: Option<&str>) -> RenderMermaid {
.unwrap_or_default()
}
/// Replace cached `render_mermaid` (optimistic update from the settings modal).
/// Replace cached `render_mermaid` (optimistic write from the settings modal).
pub fn set_render_mermaid(value: RenderMermaid) {
RENDER_MERMAID_CURRENT.with(|c| c.set(value));
RENDER_MERMAID_LOADED.with(|l| l.set(true));
}
// -- Prime + read path ------------------------------------------------------
// Prime + read path
/// Seed all caches from the live `UiConfig` at startup so subsequent
/// `load*()` calls never hit disk on the render hot path.
@@ -643,7 +643,7 @@ fn load_str_from_effective_config(key: &str) -> Option<String> {
.map(str::to_string)
}
// -- Tests -------------------------------------------------------------------
// Tests
#[cfg(test)]
mod tests {
@@ -915,7 +915,7 @@ mod tests {
#[test]
fn caches_are_independent() {
std::thread::spawn(|| {
// ── compact independent (the other two stay true) ──
// compact independent (the other two stay true)
set(false);
set_timestamps(true);
set_simple_mode(true);
@@ -929,7 +929,7 @@ mod tests {
"simple_mode must NOT toggle when compact changed"
);
// ── timestamps independent ──
// timestamps independent
set(true);
set_timestamps(false);
set_simple_mode(true);
@@ -940,7 +940,7 @@ mod tests {
"simple_mode must NOT toggle when timestamps changed"
);
// ── simple_mode independent ──
// simple_mode independent
set(true);
set_timestamps(true);
set_simple_mode(false);
@@ -10,9 +10,7 @@ use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use toml_edit::{DocumentMut, Item, RawString};
// ============================================================================
// Runtime Config (used by render code)
// ============================================================================
/// Background style for block content area.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
@@ -173,7 +171,8 @@ impl Default for ScrollbackDisplayConfig {
line_under_last_entry: false,
collapsed_accent_char: crate::glyphs::collapsed_accent().to_string(),
dim_accent: 0.5,
group_selection_split: true, // Mode B by default
// Mode B by default
group_selection_split: true,
highlight_overlays_border: false,
expandable_indicator: true,
expandable_indicator_running: true,
@@ -208,7 +207,8 @@ impl Default for LayoutConfig {
outer_hpad_left: 2,
outer_hpad_right: 2,
block_pad_left: 2,
block_pad_right: 2, // Match left padding for symmetry
// Match left padding for symmetry
block_pad_right: 2,
}
}
}
@@ -289,8 +289,10 @@ impl Default for ScrollbarConfig {
fn default() -> Self {
Self {
enabled: true,
gap_left: 0, // Content adjacent to scrollbar
gap_right: 0, // Scrollbar at screen edge
// Content adjacent to scrollbar
gap_left: 0,
// Scrollbar at screen edge
gap_right: 0,
scrollbar_bg: None,
scrollbar_fg: None,
}
@@ -587,8 +589,8 @@ pub struct ToolConfig {
pub dim_details: bool,
/// Bullet/icon character rendered before tool call headers.
pub bullet: ToolBullet,
// Note: bullet_accent and bullet_color were removed in the scrollback-v2 refactor.
// Bullet color is now determined by BlockContent::bullet() — each block type
// Note: bullet_accent and bullet_color are not configurable here.
// Bullet color is determined by BlockContent::bullet() — each block type
// decides its own bullet color based on state (accent color, error, default).
// Dimming for collapsed+groupable blocks is handled by EntryRenderer.
// TODO(dim_muted): add a dim factor for collapsed text styling (not just bullet/accent).
@@ -658,7 +660,8 @@ pub struct ListDirConfig {
impl Default for ListDirConfig {
fn default() -> Self {
Self {
terminal_bg: true, // Default: dark background for output
// Default: dark background for output
terminal_bg: true,
}
}
}
@@ -705,25 +708,12 @@ impl Default for ExecuteConfig {
}
}
// ============================================================================
// Raw Config (for TOML serde)
// ============================================================================
//
// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║ MAINTAINER NOTE: When adding/changing fields or sections: ║
// ║ ║
// ║ 1. Add doc comments (///) to ALL fields in Raw* structs - they become ║
// ║ TOML comments via the `DocumentedFields` derive macro. ║
// ║ ║
// ║ 2. If adding a new section (e.g., RawNewBlockConfig): ║
// ║ - Add it to RawBlocksConfig (or appropriate parent) ║
// ║ - Add corresponding runtime config (NewBlockConfig) ║
// ║ - Add From<RawNewBlockConfig> for NewBlockConfig conversion ║
// ║ - Add annotate_table call in to_toml_with_comments() below! ║
// ║ ║
// ║ 3. The to_toml_with_comments() method generates the default config file ║
// ║ with comments. Update it when adding new sections. ║
// ╚═══════════════════════════════════════════════════════════════════════════╝
// MAINTAINER NOTE — when adding or changing fields or sections:
// 1. Put `///` on every Raw* field (DocumentedFields emits TOML comments).
// 2. For a new section: Raw* + runtime config + From impl + annotate_table.
// 3. Keep to_toml_with_comments() in sync so the default file stays documented.
/// Root appearance configuration (TOML format).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Documented, DocumentedFields)]
@@ -1259,7 +1249,7 @@ pub struct RawToolConfig {
/// "none", "dot" (·), "small-circle" (•), "circle" (●),
/// "small-triangle" (▸), "triangle" (▶), "diamond" (◆).
pub bullet: RawToolBullet,
// Note: bullet_accent and bullet_color removed — see ToolConfig comment.
// Note: bullet_accent and bullet_color are not configurable — see ToolConfig comment.
}
impl Default for RawToolConfig {
@@ -1284,7 +1274,8 @@ pub struct RawListDirConfig {
impl Default for RawListDirConfig {
fn default() -> Self {
Self {
terminal_bg: true, // Default: dark background for output
// Default: dark background for output
terminal_bg: true,
}
}
}
@@ -1364,9 +1355,7 @@ impl From<RawBlockBackground> for BlockBackground {
}
}
// ============================================================================
// Raw → Runtime Conversion
// ============================================================================
impl From<RawAppearanceConfig> for AppearanceConfig {
fn from(raw: RawAppearanceConfig) -> Self {
@@ -1376,7 +1365,8 @@ impl From<RawAppearanceConfig> for AppearanceConfig {
collapse_unfocused: raw.prompt.collapse_unfocused,
mouse_hover: raw.prompt.mouse_hover,
show_prefix: raw.prompt.show_prefix,
compact: false, // runtime-only, not persisted in TOML
// runtime-only, not persisted in TOML
compact: false,
},
scrollback: ScrollbackConfig {
layout: raw.scrollback.layout.into(),
@@ -1426,7 +1416,8 @@ impl From<RawAppearanceConfig> for AppearanceConfig {
badge_format: raw.todo.badge_format.into(),
},
turn_status: TurnStatusConfig::default(),
show_timestamps: true, // runtime-only, loaded from config.toml via persist
// runtime-only, loaded from config.toml via persist
show_timestamps: true,
// Single source: UiConfig::SHOW_TIMELINE_DEFAULT (loaded from config.toml via persist).
show_timeline: UiConfig::SHOW_TIMELINE_DEFAULT,
disable_plugins: raw.disable_plugins,
@@ -1607,9 +1598,7 @@ impl From<RawThinkingConfig> for ThinkingConfig {
}
}
// ============================================================================
// Color Parsing
// ============================================================================
/// An optional color that can be "none" or a color value.
/// This allows TOML to represent None values explicitly.
@@ -1719,47 +1708,48 @@ fn lookup_named_color(name: &str) -> Result<Color, String> {
// `parse_color_string` → `quantize()` to match the terminal's capabilities.
let color = match name.to_uppercase().as_str() {
// Background colors
"BG" | "BG_BASE" => Color::Rgb(20, 20, 20), // #141414
"BG_LIGHT" | "BG_HIGHLIGHT" => Color::Rgb(30, 30, 30), // #1e1e1e
"BG_DARK" => Color::Rgb(17, 17, 17), // #111111
"BG_TERMINAL" | "BG_NIGHT" => Color::Rgb(10, 10, 10), // #0a0a0a
"BG_VISUAL" => Color::Rgb(30, 32, 45), // blue-tinted selection
"BG_SEARCH" => Color::Rgb(48, 48, 52), // #303034
"BG" | "BG_BASE" => Color::Rgb(20, 20, 20),
"BG_LIGHT" | "BG_HIGHLIGHT" => Color::Rgb(30, 30, 30),
"BG_DARK" => Color::Rgb(17, 17, 17),
"BG_TERMINAL" | "BG_NIGHT" => Color::Rgb(10, 10, 10),
// blue-tinted selection
"BG_VISUAL" => Color::Rgb(30, 32, 45),
"BG_SEARCH" => Color::Rgb(48, 48, 52),
// Accent colors (TokyoNight Night)
"BLUE" => Color::Rgb(77, 121, 255), // #4D79FF
"BLUE0" => Color::Rgb(61, 89, 161), // #3d59a1
"BLUE1" => Color::Rgb(42, 195, 222), // #2ac3de
"BLUE2" => Color::Rgb(13, 185, 215), // #0db9d7
"BLUE5" => Color::Rgb(137, 221, 255), // #89ddff
"BLUE6" => Color::Rgb(180, 249, 248), // #b4f9f8
"BLUE7" => Color::Rgb(57, 75, 112), // #394b70
"CYAN" => Color::Rgb(125, 207, 255), // #7dcfff
"GREEN" => Color::Rgb(36, 196, 116), // #24C474
"GREEN1" => Color::Rgb(115, 218, 202), // #73daca
"GREEN2" => Color::Rgb(65, 166, 181), // #41a6b5
"YELLOW" => Color::Rgb(255, 219, 141), // #FFDB8D
"ORANGE" => Color::Rgb(255, 158, 100), // #ff9e64
"RED" => Color::Rgb(248, 114, 122), // #F8727A
"RED1" => Color::Rgb(219, 75, 75), // #db4b4b
"MAGENTA" => Color::Rgb(187, 154, 247), // #bb9af7
"PURPLE" => Color::Rgb(131, 113, 211), // #8371D3
"MAGENTA2" => Color::Rgb(255, 0, 124), // #ff007c
"TEAL" | "HINT" => Color::Rgb(26, 188, 156), // #1abc9c
"BLUE" => Color::Rgb(77, 121, 255),
"BLUE0" => Color::Rgb(61, 89, 161),
"BLUE1" => Color::Rgb(42, 195, 222),
"BLUE2" => Color::Rgb(13, 185, 215),
"BLUE5" => Color::Rgb(137, 221, 255),
"BLUE6" => Color::Rgb(180, 249, 248),
"BLUE7" => Color::Rgb(57, 75, 112),
"CYAN" => Color::Rgb(125, 207, 255),
"GREEN" => Color::Rgb(36, 196, 116),
"GREEN1" => Color::Rgb(115, 218, 202),
"GREEN2" => Color::Rgb(65, 166, 181),
"YELLOW" => Color::Rgb(255, 219, 141),
"ORANGE" => Color::Rgb(255, 158, 100),
"RED" => Color::Rgb(248, 114, 122),
"RED1" => Color::Rgb(219, 75, 75),
"MAGENTA" => Color::Rgb(187, 154, 247),
"PURPLE" => Color::Rgb(131, 113, 211),
"MAGENTA2" => Color::Rgb(255, 0, 124),
"TEAL" | "HINT" => Color::Rgb(26, 188, 156),
// Text colors
"FG" | "TEXT" | "TEXT_PRIMARY" => Color::Rgb(243, 243, 243), // #f3f3f3
"FG_DARK" | "TEXT_SECONDARY" => Color::Rgb(200, 200, 200), // #c8c8c8
"FG_GUTTER" => Color::Rgb(65, 65, 65), // #414141
"COMMENT" | "MUTED" | "TEXT_MUTED" => Color::Rgb(98, 98, 98), // #626262
"DARK3" => Color::Rgb(90, 90, 90), // #5a5a5a
"DARK5" | "TOOL" => Color::Rgb(120, 120, 120), // #787878
"FG" | "TEXT" | "TEXT_PRIMARY" => Color::Rgb(243, 243, 243),
"FG_DARK" | "TEXT_SECONDARY" => Color::Rgb(200, 200, 200),
"FG_GUTTER" => Color::Rgb(65, 65, 65),
"COMMENT" | "MUTED" | "TEXT_MUTED" => Color::Rgb(98, 98, 98),
"DARK3" => Color::Rgb(90, 90, 90),
"DARK5" | "TOOL" => Color::Rgb(120, 120, 120),
// Semantic colors
"ERROR" => Color::Rgb(247, 118, 142), // RED
"SUCCESS" => Color::Rgb(158, 206, 106), // GREEN
"WARNING" => Color::Rgb(224, 175, 104), // YELLOW
"INFO" => Color::Rgb(125, 207, 255), // CYAN
"ERROR" => Color::Rgb(247, 118, 142),
"SUCCESS" => Color::Rgb(158, 206, 106),
"WARNING" => Color::Rgb(224, 175, 104),
"INFO" => Color::Rgb(125, 207, 255),
// Basic colors
"BLACK" => Color::Black,
@@ -1771,9 +1761,7 @@ fn lookup_named_color(name: &str) -> Result<Color, String> {
Ok(color)
}
// ============================================================================
// TOML Generation with Comments
// ============================================================================
impl RawAppearanceConfig {
pub fn to_toml_with_comments() -> String {
@@ -2025,9 +2013,7 @@ fn annotate_table<T: DocumentedFields>(table: &mut toml_edit::Table) {
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
@@ -2414,7 +2400,7 @@ gutter_bg = true
);
}
// ── Terminal config (alt_screen) parsing ─────────────────────
// Terminal config (alt_screen) parsing
#[test]
fn terminal_alt_screen_auto_default() {
@@ -33,7 +33,7 @@ pub use scroll_mode::ScrollMode;
pub use text_selection::TextSelection;
pub use watcher::ConfigWatcher;
// -- Global tab_width --------------------------------------------------------
// Global tab_width
//
// Stored as an atomic so MarkdownContent can read the current value
// without needing the AppearanceConfig threaded through its API.
@@ -62,7 +62,7 @@ impl DefaultSelectedPermission {
}
}
/// Display label for the settings picker and the change toast.
/// Display label for the settings picker and the confirmation toast.
/// `AllowCommandAlways` preselects the prompt-specific always-allow row
/// (per-command / per-tool / per-domain / per-edit-session), never a
/// global allow-everything — that is `AlwaysAllowAllSessions`.
@@ -132,7 +132,7 @@ impl DefaultSelectedPermission {
}
}
// ── Configured value cache: `[ui].default_selected_permission` ──────────────
// Configured value cache: `[ui].default_selected_permission`
//
// Read when queueing the first prompt of a session. Seeded by `prime` at
// startup (and lazily on first read) so the path never hits disk mid-session.
@@ -174,7 +174,7 @@ pub fn load_default_selected_permission() -> DefaultSelectedPermission {
CONFIG_CURRENT.with(Cell::get)
}
/// Replace the cached configured value (optimistic update from the settings
/// Replace the cached configured value (optimistic write from the settings
/// modal, or rollback on persist failure). The next prompt sees it without a
/// restart.
pub fn set_default_selected_permission(value: DefaultSelectedPermission) {
@@ -188,7 +188,7 @@ pub fn prime() {
let _ = load_default_selected_permission();
}
// ── Sticky "last used" cursor target ────────────────────────────────────────
// Sticky "last used" cursor target
//
// Process-wide ephemeral state: the kind the user most recently confirmed.
// After the first prompt, `resolve_initial_cursor` prefers this over the
@@ -215,7 +215,7 @@ pub fn set_last_used_permission(kind: DefaultSelectedPermission) {
LAST_USED.with(|c| c.set(kind));
}
// ── Resolution ──────────────────────────────────────────────────────────────
// Resolution
/// Pick the initially-highlighted row for a freshly-queued permission prompt.
///
@@ -252,7 +252,7 @@ fn load_string_from_effective_config(key: &str) -> Option<String> {
.map(std::string::ToString::to_string)
}
// -- Tests -------------------------------------------------------------------
// Tests
#[cfg(test)]
mod tests {
@@ -2,7 +2,7 @@
//!
//! Fenced ` ```mermaid ` blocks are rendered inline as Unicode box-drawing art
//! by the markdown renderer. This setting controls the full-fidelity affordance
//! row layered beneath that art: `auto`/`on` add the clickable row
//! row layered beneath that art: `auto`/`on` include the clickable row
//! (`◇ mermaid [Open Image] [Copy Image Path] [Copy Source]`); `off` shows the
//! inline art alone. The PNG render engine is always compiled in, and the PNG is
//! never drawn as an inline image (it opens in the OS viewer), so the treatment
@@ -6,10 +6,9 @@
//! `ScrollInputMode` when building its scroll config; this crate only owns
//! the persisted value type and its cache.
/// Scroll input classification preference: auto-detect or force one kind.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum ScrollMode {
/// Detect wheel vs trackpad per stream from event timing. Default.
/// Detect wheel vs trackpad per stream from event timing.
#[default]
Auto,
/// Always treat scroll input as a mouse wheel (fixed lines per tick).
@@ -28,7 +27,6 @@ impl ScrollMode {
}
}
/// Parse a canonical string, returning `None` for unrecognized input.
pub fn from_canonical(value: &str) -> Option<Self> {
match value {
"auto" => Some(Self::Auto),
@@ -52,8 +50,8 @@ mod tests {
#[test]
fn junk_and_case_variants_are_rejected() {
// Strict parse: unknown disk/env values must fall back to the default
// at the caller (cache seed), never panic or mis-map.
// Parsing is strict so a stray disk/env value falls back to the
// default at the caller (cache seed) rather than mis-mapping.
for junk in ["", "Auto", "WHEEL", "track pad", "mouse", "1"] {
assert_eq!(ScrollMode::from_canonical(junk), None, "{junk:?}");
}
@@ -1,18 +1,12 @@
//! The `keep_text_selection` user setting (`flash` | `hold` | `word_select`).
//!
//! This is the single, unified control for scrollback text-selection behavior.
//! It governs both how long an in-app selection highlight stays on screen and
//! what a double/triple-click does, so the two can never drift out of sync:
//!
//! - `flash` — brief highlight on mouse-up, then clear; double-click toggles fold.
//! - `hold` — selection stays until dismissed; double-click toggles fold.
//! - `word_select` — selection stays until dismissed; double-click selects &
//! copies a word, triple-click a line (terminal-like). Implies `hold`.
//! One setting governs both how long an in-app selection highlight stays on
//! screen and what a double/triple-click does, so the two can never drift out
//! of sync.
/// Scrollback text-selection behavior: highlight lifetime + double-click action.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum TextSelection {
/// Brief highlight on mouse-up, then clear; double-click toggles fold. Default.
/// Brief highlight on mouse-up, then clear; double-click toggles fold.
#[default]
Flash,
/// Stay visible until Esc/click/scroll; double-click toggles fold.
@@ -32,7 +26,6 @@ impl TextSelection {
}
}
/// Parse a canonical string, returning `None` for unrecognized input.
pub fn from_canonical(value: &str) -> Option<Self> {
match value {
"flash" => Some(Self::Flash),
@@ -78,16 +71,14 @@ mod tests {
assert_eq!(TextSelection::default().as_canonical(), "flash");
}
/// The unified invariant: `word_select` always implies `holds()` (persistent
/// highlight) and is the only mode that turns on double-click word select.
/// `word_select` always implies `holds()`, and is the only mode that turns
/// on double-click word select.
#[test]
fn word_select_implies_hold_and_word_select() {
assert!(TextSelection::WordSelect.holds());
assert!(TextSelection::WordSelect.selects_word());
// Hold persists but leaves double-click as fold-toggle.
assert!(TextSelection::Hold.holds());
assert!(!TextSelection::Hold.selects_word());
// Flash neither persists nor word-selects.
assert!(!TextSelection::Flash.holds());
assert!(!TextSelection::Flash.selects_word());
}
@@ -13,7 +13,7 @@ use std::sync::OnceLock;
use crate::terminal::{MultiplexerKind, TerminalContext};
/// Cached result of the remote-session check (env vars don't change at runtime).
/// Cached result of the remote-session check (env vars do not alter at runtime).
fn is_remote() -> bool {
static REMOTE: OnceLock<bool> = OnceLock::new();
*REMOTE.get_or_init(kigi_shared::clipboard::is_remote_session)
@@ -270,7 +270,7 @@ pub struct CopyResult {
/// Kind of clipboard copy toast (success route or failure).
///
/// Telemetry labels come from `IntoStaticStr` (`snake_case`); user-facing copy
/// lives in [`ClipboardToastKind::message`] (intentionally different).
/// lives in [`ClipboardToastKind::message`] (deliberately different).
#[derive(Debug, Clone, Copy, Eq, PartialEq, strum::IntoStaticStr)]
#[strum(serialize_all = "snake_case")]
pub(crate) enum ClipboardToastKind {
@@ -442,7 +442,7 @@ pub fn clipboard_text_is_pasteable(text: Option<&str>) -> bool {
}
/// Telemetry when a paste key was pressed but the host clipboard had nothing
/// pasteable. Behavior is unchanged — callers still consume the key.
/// pasteable. Behavior is `unchanged` — callers still consume the key.
/// Emits structured logs and a product analytics event when telemetry is enabled.
pub fn log_paste_key_empty_host_clipboard(surface: &str) {
let terminal = crate::terminal::terminal_context().diagnostics_snapshot();
@@ -619,7 +619,7 @@ fn should_run_attachment_probe(
/// probe; `Some(change_count)` = probe, carrying the pasteboard `changeCount`
/// this gate's OWN snapshot read observed. Enqueue sites thread that baseline
/// into the off-thread probe's staleness check instead of taking a second
/// native read that could land after a clipboard change.
/// native read that could land after a clipboard write.
///
/// Cheap (native snapshot only, no subprocess) so paste handlers can call it on
/// the event loop to decide whether to DEFER the heavy probe to a background
@@ -769,9 +769,7 @@ pub fn system_clipboard_get_image() -> Option<ImageData> {
system_clipboard_get_image_result().unwrap_or(None)
}
// ===========================================================================
// Test support
// ===========================================================================
/// Injectable clipboard reads for driving the paste handlers in tests without
/// spawning `pbpaste` / `osascript`.
@@ -876,7 +874,7 @@ pub mod test_support {
PRIMARY_READS.with(|c| c.set(0));
}
/// Remove the canned clipboard hook and reset the probe counter.
/// Drop the canned clipboard hook and reset the probe counter.
pub fn clear_clipboard_probe_hook() {
HOOK.with(|h| *h.borrow_mut() = None);
PROBE_CALLS.with(|c| c.set(0));
@@ -959,9 +957,7 @@ pub use test_support::{
set_clipboard_probe_hook,
};
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
mod tests {
@@ -971,7 +967,7 @@ mod tests {
TmuxClientMeta,
};
// -- Context builders for clipboard route tests ---------------------------
// Context builders for clipboard route tests
fn plain_terminal_ctx() -> TerminalContext {
TerminalContext {
@@ -1091,7 +1087,7 @@ mod tests {
);
}
// -- Bracketed payload ↔ clipboard text match ------------------------------
// Bracketed payload ↔ clipboard text match
#[test]
fn bracketed_payload_match_exact_and_normalized() {
@@ -1338,9 +1334,7 @@ mod tests {
}
}
// =====================================================================
// resolve_clipboard_route: pure routing logic
// =====================================================================
#[derive(Debug)]
struct ClipboardRouteCase {
@@ -1421,9 +1415,7 @@ mod tests {
}
}
// =====================================================================
// ClipboardRoute structure
// =====================================================================
#[test]
fn clipboard_route_native_always_true() {
@@ -1501,11 +1493,9 @@ mod tests {
assert!(!resolve_clipboard_route(&plain_terminal_ctx()).osc52_tmux_passthrough);
}
// =====================================================================
// Extended clipboard route matrix (final hardening)
// =====================================================================
// -- Byobu-screen: native only, no tmux buffer, no OSC 52 ----------------
// Byobu-screen: native only, no tmux buffer, no OSC 52
#[test]
fn clipboard_route_byobu_screen_no_tmux_buffer_no_osc52() {
@@ -1518,7 +1508,7 @@ mod tests {
// OSC 52 depends on is_remote(), but tmux_buffer must be false.
}
// -- Plain screen: no tmux buffer -----------------------------------------
// Plain screen: no tmux buffer
#[test]
fn clipboard_route_plain_screen_no_tmux_buffer() {
@@ -1530,7 +1520,7 @@ mod tests {
);
}
// -- Consistency: all environments always have native = true ---------------
// Consistency: all environments always have native = true
#[test]
fn clipboard_route_native_never_disabled() {
@@ -1552,7 +1542,7 @@ mod tests {
}
}
// -- tmux-backed: all three legs are active --------------------------------
// tmux-backed: all three legs are active
#[test]
fn clipboard_route_tmux_backed_all_three_legs() {
@@ -1564,7 +1554,7 @@ mod tests {
}
}
// -- Non-tmux-backed: tmux_buffer always false ----------------------------
// Non-tmux-backed: tmux_buffer always false
#[test]
fn clipboard_route_non_tmux_never_tmux_buffer() {
@@ -64,9 +64,8 @@ pub(crate) fn trusted_osc(
}
/// Toast from legs + env: native → OSC (incl. VS Code remote non-ASCII) → tmux → Failed.
// Pure decision function over independent environment inputs (host OS, display
// server, remote/container/sink flags). Bundling them into a struct would only
// move the argument list elsewhere and churn every call site/test.
// The arguments are independent environment inputs; bundling them into a struct
// would only move the same list to every call site.
#[allow(clippy::too_many_arguments)]
pub(crate) fn resolve_copy_toast(
legs: &ClipboardWriteLegs,
@@ -85,7 +84,8 @@ pub(crate) fn resolve_copy_toast(
if remote && brand.is_vscode_family() && !text.is_ascii() {
return ClipboardToastKind::VsCodeSshNonAscii;
}
// Container before remote (matches prior route-flag toast order).
// A remote container reports the container toast: its fallback hint is
// the actionable one.
if container {
return ClipboardToastKind::CopiedOscContainer;
}
@@ -126,7 +126,6 @@ mod tests {
}
}
/// Same as [`legs`] with the Wayland data-control flag set.
fn legs_data_control(
route_native: bool,
cli_ok: bool,
@@ -249,9 +248,8 @@ mod tests {
);
}
// The enterprise clipboard shape after the fix: no CLI tool installed, but the
// arboard write went through the compositor's data-control protocol, so it
// is trusted native.
// Locked-down enterprise desktop: no clipboard CLI installed, but the
// arboard write reached the compositor via data-control.
#[test]
fn linux_wayland_arboard_data_control_ok() {
let l = legs_data_control(true, false, true, false, true, "");
@@ -268,8 +266,7 @@ mod tests {
);
}
// Without data-control (GNOME <= 47 or kill-switch), an arboard-only write
// keeps the `linux_wayland_arboard_only_fails` semantics.
// GNOME <= 47 or the kill-switch: no data-control protocol available.
#[test]
fn linux_wayland_arboard_without_data_control_still_fails() {
let l = legs(true, false, true, false, false, "");
@@ -286,7 +283,6 @@ mod tests {
);
}
// Data-control grants nothing when the arboard write itself failed.
#[test]
fn linux_wayland_data_control_without_arboard_fails() {
let l = legs_data_control(true, false, false, false, false, "");
@@ -385,7 +381,7 @@ mod tests {
#[test]
fn ssh_iterm2_osc_only_remote_toast() {
// Guards the OSC-52 membership invariant the fix depends on.
// The remote toast only holds while Iterm2 is in the OSC-52 brand set.
assert!(TerminalName::Iterm2.supports_osc52_clipboard());
let l = legs(true, false, false, false, true, "");
assert_eq!(
@@ -550,13 +546,13 @@ mod tests {
);
}
// `kigi wrap` sink: a brand that does NOT natively support OSC 52 (the
// common SSH case where the inner terminal is misdetected as Vte/Unknown)
// is still trusted when an upstream OSC 52 sink is capturing our output.
// The common SSH case: the inner terminal is misdetected as Vte, which does
// not natively support OSC 52, yet the `kigi wrap` sink upstream does.
#[test]
fn wrapped_ssh_vte_osc_trusted_via_sink() {
let l = legs(true, false, false, false, true, "");
// Without the sink: untrusted brand over SSH → Failed.
// Trailing arg is the sink flag: without it, an untrusted brand over
// SSH fails closed.
assert_eq!(
resolve_copy_toast(
&l,
@@ -570,7 +566,7 @@ mod tests {
),
ClipboardToastKind::Failed
);
// With the sink active: trusted → success toast.
// Same inputs with the sink active.
assert_eq!(
resolve_copy_toast(
&l,
@@ -586,8 +582,6 @@ mod tests {
);
}
// Sink trust still requires an actual OSC 52 write to have happened
// (`osc52_ok`); it never fabricates success when no leg fired.
#[test]
fn wrapped_sink_without_osc_write_still_fails() {
let l = legs(true, false, false, false, false, "");
@@ -607,11 +601,9 @@ mod tests {
);
}
// Docker/podman from Windows PowerShell / cmd (or any host terminal):
// brand env vars are not forwarded into the container, so the brand is
// Unknown; native legs cannot work (no display server). The emitted
// OSC 52 is the copy path and must be trusted → hedged container toast,
// not "Copy failed" (regression test for the false-failure report).
// Regression test for the false "Copy failed" toast in docker: the runtime
// does not forward brand env vars, so the brand is Unknown even though the
// outer terminal applies OSC 52 fine. See [`trusted_osc`].
#[test]
fn container_unknown_brand_osc_trusted() {
let l = legs(true, false, false, false, true, "");
@@ -628,7 +620,6 @@ mod tests {
);
}
// Container trust never fabricates success: no OSC 52 write → Failed.
#[test]
fn container_unknown_brand_without_osc_write_fails() {
let l = legs(true, false, false, false, false, "");
@@ -646,8 +637,8 @@ mod tests {
);
}
// A *detected* non-supporting brand stays fail-closed even in a container
// (env was explicitly forwarded, so the detection is authoritative).
// A brand that survived into the container means the env was explicitly
// forwarded, so the detection is authoritative and stays fail-closed.
#[test]
fn container_detected_nonsupporting_brand_fails() {
let l = legs(true, false, false, false, true, "");
@@ -664,8 +655,8 @@ mod tests {
);
}
// Unknown brand over SSH (not container) keeps failing closed — the
// container override is deliberately narrow; `kigi wrap` is the SSH path.
// The container override is deliberately narrow: plain SSH keeps failing
// closed, since `kigi wrap` is the supported SSH path.
#[test]
fn ssh_unknown_brand_osc_only_still_fails() {
let l = legs(true, false, false, false, true, "");
@@ -682,7 +673,6 @@ mod tests {
);
}
// Sink in a container (no display) → container OSC toast.
#[test]
fn wrapped_container_osc_trusted_via_sink() {
let l = legs(true, false, false, false, true, "");
@@ -28,7 +28,8 @@ pub(super) struct XorShift64(u64);
impl XorShift64 {
pub fn new(seed: u64) -> Self {
Self(seed.max(1)) // xorshift state must be non-zero
// xorshift state must be non-zero
Self(seed.max(1))
}
pub fn next_u32(&mut self) -> u32 {
@@ -239,30 +240,34 @@ fn hellstone() -> Texture {
Texture { pixels }
}
// -------------------------------------------------------------------------
// Sprite art (char-map pixel art)
// -------------------------------------------------------------------------
/// Map a sprite art character to a color. `.` is transparent.
fn sprite_color(ch: u8) -> Option<Rgb> {
match ch {
b'.' => None,
b'B' => Some([146, 90, 50]), // imp body, brown
b'b' => Some([104, 62, 34]), // imp body, shaded
b'H' => Some([222, 214, 188]), // horn / bone
b'E' => Some(EYE_GLOW), // glowing eye (fog-exempt in renderer)
b'M' => Some([34, 20, 16]), // mouth / dark recess
b'T' => Some([236, 232, 220]), // teeth
b'C' => Some([214, 196, 160]), // claw
b'R' => Some([186, 28, 24]), // blood
b'r' => Some([120, 16, 14]), // blood, dark
b'G' => Some([96, 104, 112]), // gunmetal
b'g' => Some([52, 58, 66]), // gunmetal, dark
b'W' => Some([224, 228, 232]), // highlight
b'S' => Some([212, 160, 116]), // skin
b's' => Some([164, 116, 80]), // skin, shaded
b'F' => Some([255, 244, 160]), // muzzle flash core
b'f' => Some([255, 168, 48]), // muzzle flash fringe
// imp body, brown
b'B' => Some([146, 90, 50]),
// imp body, shaded
b'b' => Some([104, 62, 34]),
b'H' => Some([222, 214, 188]),
// glowing eye (fog-exempt in renderer)
b'E' => Some(EYE_GLOW),
// mouth / dark recess
b'M' => Some([34, 20, 16]),
b'T' => Some([236, 232, 220]),
b'C' => Some([214, 196, 160]),
b'R' => Some([186, 28, 24]),
b'r' => Some([120, 16, 14]),
b'G' => Some([96, 104, 112]),
b'g' => Some([52, 58, 66]),
b'W' => Some([224, 228, 232]),
b'S' => Some([212, 160, 116]),
b's' => Some([164, 116, 80]),
// muzzle flash core
b'F' => Some([255, 244, 160]),
// muzzle flash fringe
b'f' => Some([255, 168, 48]),
_ => None,
}
}
@@ -530,9 +535,7 @@ pub(super) fn build_gun_sprites() -> GunSprites {
GunSprites { idle, fire }
}
// -------------------------------------------------------------------------
// 5x7 pixel font (uppercase + the few symbols the game needs)
// -------------------------------------------------------------------------
/// Return the 5x7 glyph rows for a character, MSB-left in the low 5 bits.
/// Unknown characters render as blank.
@@ -24,8 +24,10 @@ const VIGNETTE: f32 = 0.11;
pub(super) struct FrameBuffer {
pub w: usize,
pub h: usize,
pub pixels: Vec<u8>, // RGB8, row-major
zbuf: Vec<f32>, // per-column wall depth
// RGB8, row-major
pub pixels: Vec<u8>,
// per-column wall depth
zbuf: Vec<f32>,
/// Per-column wall strip bounds `[top, bottom)` in screen rows, written
/// by `draw_walls` and read by `draw_floor_ceiling` to skip the pixels
/// walls already cover (avoids texturing them twice).
@@ -33,7 +35,7 @@ pub(super) struct FrameBuffer {
wall_bottom: Vec<i32>,
/// Scratch for painter's-order sprite sorting, reused across frames.
sprite_order: Vec<(usize, f32)>,
/// Separable vignette factors, rebuilt on dimension change.
/// Separable vignette factors, rebuilt on dimension shift.
vig_x: Vec<f32>,
vig_y: Vec<f32>,
}
@@ -369,7 +371,8 @@ impl Renderer {
let tx = inv_det * (dir_y * rel_x - dir_x * rel_y);
let ty = inv_det * (-plane_y * rel_x + plane_x * rel_y);
if ty <= 0.08 {
continue; // behind or on top of the camera
// behind or on top of the camera
continue;
}
let sprite = match imp.visual() {
@@ -411,7 +414,8 @@ impl Renderer {
for sx in x0.max(0)..x1.min(w as i32) {
if fb.zbuf[sx as usize] <= ty {
continue; // occluded by a wall
// occluded by a wall
continue;
}
let u = (sx as f32 - x0 as f32) / (x1 - x0).max(1) as f32;
for sy in y0.max(0)..y1.min(h as i32) {
@@ -520,9 +524,7 @@ fn draw_contact_shadow(
}
}
// -------------------------------------------------------------------------
// Title / end screens: animated fire + 5x7 pixel text
// -------------------------------------------------------------------------
/// The classic PSX-style fire effect: a cellular automaton on a coarse
/// grid, upscaled at draw time. Heat values 0..=36 index a fire palette.
@@ -557,8 +559,9 @@ impl FireSim {
for x in 0..self.w {
let src = y * self.w + x;
let r = self.rng.next_u32();
let decay = (r & 1) as i32; // cool by 0 or 1
let drift = (r >> 2) % 3; // 0, 1, 2 → left, stay, right
let decay = (r & 1) as i32;
// 0, 1, 2 → left, stay, right
let drift = (r >> 2) % 3;
let dst_x = (x as i32 + drift as i32 - 1).rem_euclid(self.w as i32) as usize;
let dst = (y - 1) * self.w + dst_x;
self.heat[dst] = (self.heat[src] as i32 - decay).max(0) as u8;
@@ -696,7 +699,7 @@ mod tests {
let renderer = Renderer::new();
let mut game = Game::new();
// Move all imps far behind the player so none are visible, render,
// then put one directly in front and confirm pixels change.
// then put one directly in front and confirm pixels differ.
for imp in &mut game.imps {
imp.x = game.player.x - 8.0;
imp.y = game.player.y;
@@ -44,8 +44,10 @@ const IMP_RADIUS: f32 = 0.30;
/// hold timer ([`HOLD_WINDOW`]); while it's positive, velocity eases toward
/// a steady target. A constant target while held means speed doesn't
/// sawtooth with the OS key-repeat cadence, yet releasing glides to a stop.
const MOVE_SPEED: f32 = 3.3; // tiles/s while a move key is held
const TURN_SPEED: f32 = 2.2; // rad/s (~125°/s) while a turn key is held
// tiles/s while a move key is held
const MOVE_SPEED: f32 = 3.3;
// rad/s (~125°/s) while a turn key is held
const TURN_SPEED: f32 = 2.2;
/// Velocity-smoothing time constants (seconds). Small = snappy response
/// with just enough ramp to read as momentum rather than teleporting.
const MOVE_ACCEL_TAU: f32 = 0.08;
@@ -78,7 +80,8 @@ const IMP_BITE_DAMAGE: i32 = 7;
pub(super) struct Map {
pub w: usize,
pub h: usize,
cells: Vec<u8>, // 0 = floor, 1..=4 = wall texture id
// 0 = floor, 1..=4 = wall texture id
cells: Vec<u8>,
}
impl Map {
@@ -166,7 +169,6 @@ impl Map {
}
}
/// Player state.
pub(super) struct Player {
pub x: f32,
pub y: f32,
@@ -330,7 +332,8 @@ impl Game {
y: center.1,
hp: IMP_HP,
state: ImpState::Idle,
anim: (x * 7 + y * 13) as f32 * 0.1, // desync walk cycles
// desync walk cycles
anim: (x * 7 + y * 13) as f32 * 0.1,
attack_cooldown: 0.0,
}),
_ => {}
@@ -728,7 +731,8 @@ mod tests {
assert!(game.imps[0].hp < hp_before, "first shot must connect");
for _ in 0..20 {
game.step(FIRE_COOLDOWN + 0.01); // let cooldown lapse
// let cooldown lapse
game.step(FIRE_COOLDOWN + 0.01);
game.queue_fire();
game.step(0.016);
if !game.imps[0].alive() {
@@ -807,7 +811,7 @@ mod tests {
// and then stay there — no sawtooth.
let mut game = Game::new();
// Aim down an open stretch so walls don't cap velocity.
game.player.angle = std::f32::consts::FRAC_PI_2; // +y
game.player.angle = std::f32::consts::FRAC_PI_2;
let dt = 1.0 / 30.0;
for _ in 0..40 {
game.press(Control::Forward);
@@ -837,13 +841,14 @@ mod tests {
// on press (release-aware) keeps it moving without repeats.
let mut game = Game::new();
game.set_release_aware(true);
game.player.angle = 0.0; // facing +x
game.player.angle = 0.0;
game.press(Control::Forward);
game.press(Control::TurnLeft);
let angle0 = game.player.angle;
for _ in 0..30 {
game.step(1.0 / 30.0); // no further presses
// no further presses
game.step(1.0 / 30.0);
}
assert!(
game.player.vel_forward > 1.0,
@@ -893,7 +898,8 @@ mod tests {
let mut game = Game::new();
game.player.angle = std::f32::consts::FRAC_PI_2;
let dt = 1.0 / 60.0;
let mut since_repeat = repeat_interval; // press on the first frame
// press on the first frame
let mut since_repeat = repeat_interval;
// Run 2s of simulation, pressing every `repeat_interval`.
for _ in 0..120 {
since_repeat += dt;
@@ -905,8 +911,9 @@ mod tests {
}
game.player.vel_forward
}
let fast = sustained_speed(0.03); // ~33 Hz
let slow = sustained_speed(0.12); // ~8 Hz, still under HOLD_WINDOW
let fast = sustained_speed(0.03);
// ~8 Hz, still under HOLD_WINDOW
let slow = sustained_speed(0.12);
assert!((fast - MOVE_SPEED).abs() < 0.1, "fast cadence: {fast}");
assert!(
(fast - slow).abs() < 0.25,
@@ -498,7 +498,7 @@ mod tests {
#[test]
fn playing_to_dead_transition() {
let mut state = GboomState::new();
state.handle_key(&key(KeyCode::Char('w'))); // leave title
state.handle_key(&key(KeyCode::Char('w')));
state.game.player.hp = 0;
state.tick();
assert_eq!(state.phase, Phase::Dead);
@@ -682,9 +682,9 @@ mod tests {
// Corridor vantage: spawn looking south down the long west corridor.
let mut state = GboomState::new();
state.handle_key(&key(KeyCode::Char('w'))); // leave title
state.handle_key(&key(KeyCode::Char('w')));
state.phase = Phase::Playing;
state.game.player.angle = std::f32::consts::FRAC_PI_2; // +y, south
state.game.player.angle = std::f32::consts::FRAC_PI_2;
state.game.step(0.016);
dump("game.png", &mut state);
+21 -20
View File
@@ -371,7 +371,7 @@ pub fn timeline_tick_hover() -> &'static str {
/// The filled status / selection dot used in pickers, the settings and
/// permission modals, the session list, and the file-search view. Its
/// hollow partner `○` (U+25CB) is already a CP437 glyph (`0x09`) and
/// renders unchanged, so only the filled variant needs a stand-in.
/// renders `unchanged`, so only the filled variant needs a stand-in.
pub fn filled_dot() -> &'static str {
if is_legacy_windows_console() {
"\u{2022}"
@@ -500,7 +500,7 @@ pub fn enlarge_button() -> &'static str {
/// Unlike the fixed-width button helpers above, toasts are right-aligned
/// flowing text assembled in ~25 call sites, so a single funnel at the
/// point the toast enters view state is cleaner than threading a helper
/// through every builder. Returns a borrow unchanged on every non-legacy
/// through every builder. Returns a borrow `unchanged` on every non-legacy
/// platform, so toast strings stay byte-identical there.
pub fn legacy_glyph_fallback(s: &str) -> Cow<'_, str> {
if !is_legacy_windows_console() {
@@ -607,8 +607,8 @@ mod tests {
fn record_dot_states_are_one_column() {
assert_eq!(record_dot(true).width(), 1);
assert_eq!(record_dot(false).width(), 1);
assert_eq!("\u{25C9}".width(), 1); // ◉ FISHEYE
assert_eq!("\u{25CE}".width(), 1); // ◎ BULLSEYE
assert_eq!("\u{25C9}".width(), 1);
assert_eq!("\u{25CE}".width(), 1);
}
#[test]
@@ -623,11 +623,11 @@ mod tests {
#[test]
fn icon_fallback_variants_are_one_column() {
for (fancy, fallback) in [
("\u{2717}", "x"), // ballot_x
("\u{2713}", "\u{221A}"), // check_mark
("\u{2197}", "o"), // enlarge
("\u{29C9}", "c"), // copy_icon
("\u{21E3}", "\u{2193}"), // token_arrow
("\u{2717}", "x"),
("\u{2713}", "\u{221A}"),
("\u{2197}", "o"),
("\u{29C9}", "c"),
("\u{21E3}", "\u{2193}"),
] {
assert_eq!(fancy.width(), 1, "icon {fancy:?} must be 1 column");
assert_eq!(
@@ -645,9 +645,9 @@ mod tests {
#[test]
fn diamond_variants_are_one_column() {
for (fancy, fallback) in [
("\u{25C6}", "\u{2666}"), // diamond_filled
("\u{25C7}", "\u{25CB}"), // diamond_hollow
("\u{25C8}", "\u{2666}"), // diamond_dotted
("\u{25C6}", "\u{2666}"),
("\u{25C7}", "\u{25CB}"),
("\u{25C8}", "\u{2666}"),
] {
assert_eq!(fancy.width(), 1, "diamond {fancy:?} must be 1 column");
assert_eq!(
@@ -664,12 +664,12 @@ mod tests {
#[test]
fn chrome_glyph_variants_are_one_column() {
for (fancy, fallback) in [
("\u{2503}", "\u{2502}"), // accent_bar
("\u{25CF}", "\u{2022}"), // filled_dot
("\u{258F}", "\u{2502}"), // selection_bar
("\u{203A}", ">"), // chevron
("\u{2039}", "<"), // chevron_left
("\u{2304}", "v"), // chevron_down
("\u{2503}", "\u{2502}"),
("\u{25CF}", "\u{2022}"),
("\u{258F}", "\u{2502}"),
("\u{203A}", ">"),
("\u{2039}", "<"),
("\u{2304}", "v"),
] {
assert_eq!(fancy.width(), 1, "glyph {fancy:?} must be 1 column");
assert_eq!(
@@ -724,8 +724,9 @@ mod tests {
#[test]
fn button_variants_have_stable_width() {
for (fancy, fallback, cols) in [
("[\u{2717}]", "[x]", 3), // ballot_x_button
("[\u{2197}]", "[o]", 3), // enlarge_button
// ballot_x_button
("[\u{2717}]", "[x]", 3),
("[\u{2197}]", "[o]", 3),
] {
assert_eq!(fancy.width(), cols, "button {fancy:?} must be {cols} cols");
assert_eq!(
@@ -41,7 +41,7 @@ impl DisplayRefreshProbeResult {
}
}
/// Once per process. Infallible; never panics.
/// Probes once per process. Infallible; never panics.
pub fn probe_display_refresh() -> DisplayRefreshProbeResult {
static CACHE: OnceLock<DisplayRefreshProbeResult> = OnceLock::new();
*CACHE.get_or_init(probe_uncached)
@@ -78,7 +78,8 @@ fn probe_inner() -> (Option<u32>, DisplayRefreshSource, &'static str) {
decide(is_ssh, wsl, os, display, platform_hz)
}
/// Pure matrix used by production and tests; inject only the platform result.
/// Pure decision matrix; the platform result is injected so tests can drive
/// every branch without a display.
fn decide(
is_ssh: bool,
is_wsl: bool,
@@ -157,6 +158,8 @@ fn probe_macos() -> Result<u32, &'static str> {
Err("unsupported")
}
/// Any fallback added here must stay thread-safe: no AppKit/NSScreen, which is
/// main-thread only.
#[cfg(target_os = "macos")]
unsafe fn macos_main_display_refresh_hz() -> Result<u32, &'static str> {
type CgDisplayModeRef = *mut core::ffi::c_void;
@@ -178,11 +181,10 @@ unsafe fn macos_main_display_refresh_hz() -> Result<u32, &'static str> {
}
let rate = unsafe { CGDisplayModeGetRefreshRate(mode) };
unsafe { CGDisplayModeRelease(mode) };
// 0.0 is documented indeterminate for some LCD/VRR panels — skip, not error.
// Future primary-display fallback must be thread-safe; no AppKit/NSScreen here.
if !rate.is_finite() || rate < 0.0 {
return Err("error");
}
// 0.0 is documented indeterminate for some LCD/VRR panels — skip, not error.
if rate == 0.0 {
return Err("indeterminate");
}
@@ -256,9 +258,8 @@ unsafe fn windows_primary_display_refresh_hz() -> Result<u32, &'static str> {
mod tests {
use super::*;
/// Real OS path smoke: must not panic (FFI wrapped + fail-closed).
/// Outcome may be ok/skipped/error depending on host; we only require
/// process survival and a valid outcome token.
/// Exercises the real OS path, whose outcome depends on the host machine,
/// so only process survival and a valid outcome token can be asserted.
#[test]
fn probe_display_refresh_never_panics() {
let r = probe_display_refresh();
@@ -50,7 +50,7 @@ impl HostOs {
/// WSL detection. The implementation lives in `kigi-tty-utils` (the shared
/// low-level crate) so crates that must not depend on this UI crate can reuse
/// it; re-exported here so existing `host::is_wsl()` callers are unchanged.
/// it; re-exported here so existing `host::is_wsl()` callers are `unchanged`.
pub use kigi_tty_utils::is_wsl;
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
@@ -67,7 +67,7 @@ pub enum DisplayServer {
impl DisplayServer {
/// Detect the display server. Cached for process lifetime on Linux
/// (env vars don't change); compile-time constant on macOS/Windows.
/// (env vars do not alter); compile-time constant on macOS/Windows.
pub fn current() -> Self {
static CACHE: OnceLock<DisplayServer> = OnceLock::new();
*CACHE.get_or_init(|| {
@@ -116,7 +116,7 @@ mod unicode_env_tests {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
let bad = OsString::from_wide(&[0xD800]); // lone surrogate
let bad = OsString::from_wide(&[0xD800]);
let map = unicode_env_from_os([
(bad.clone(), OsString::from("ok")),
(OsString::from("OK_KEY"), bad),
@@ -18,7 +18,7 @@ pub struct ModalWindowState {
/// Full popup area (for click-outside-to-close detection).
pub popup_area: Option<Rect>,
// -- Tabs (optional) --
// Tabs (optional)
/// Currently active tab index.
pub active_tab: usize,
/// Number of tabs (0 = no tab bar).
@@ -28,7 +28,7 @@ pub struct ModalWindowState {
/// Whether the tab bar region has keyboard focus. When true, Left/Right
pub tabs_focused: bool,
// -- Footer shortcuts --
// Footer shortcuts
/// Hit-test areas for clickable footer shortcuts.
pub shortcut_hits: Vec<ShortcutHitArea>,
/// Which footer shortcut (by index) is currently hovered.
@@ -20,9 +20,7 @@ use kigi_ratatui_textarea::ElementId;
/// regressions from a single user's session capture.
pub const PROMPT_IMAGES_TRACING_TARGET: &str = "prompt_images";
// -------------------------------------------------------------------------
// Scrollable image viewer state
// -------------------------------------------------------------------------
/// State for a modal image viewer.
///
@@ -223,9 +221,7 @@ pub fn load_image_data(path: &std::path::Path) -> ImageLoadResult {
})
}
// -------------------------------------------------------------------------
// Video viewer state
// -------------------------------------------------------------------------
/// Target frames per second for terminal video playback.
const VIDEO_FPS: f64 = 10.0;
@@ -543,9 +539,7 @@ fn parse_fraction(s: &str) -> Option<f64> {
}
}
// -------------------------------------------------------------------------
// Inline media info (for scrollback inline rendering)
// -------------------------------------------------------------------------
/// Metadata for inline media rendering in the scrollback.
/// Returned by blocks that want to display media inline.
@@ -762,9 +756,7 @@ impl PromptImagePreviewPreparation {
}
}
// -------------------------------------------------------------------------
// Display helpers
// -------------------------------------------------------------------------
/// Build the buffer text for an image chip.
///
@@ -782,9 +774,7 @@ pub fn extension_for_mime(mime: &str) -> &'static str {
mime_to_extension(mime)
}
// -------------------------------------------------------------------------
// Reconciliation
// -------------------------------------------------------------------------
/// Remove entries from `images` whose `element_id` is not present in
/// `live_ids`.
@@ -798,7 +788,7 @@ pub fn reconcile(images: &mut Vec<PastedImage>, live_ids: &HashSet<ElementId>) {
return true;
}
// Clean up temp-file-only staged images for removed chips.
// Session-persisted files are intentionally left as orphans in v1.
// Session-persisted files are deliberately left as orphans in v1.
cleanup_temp_file(img);
false
});
@@ -836,16 +826,15 @@ pub fn clear(images: &mut Vec<PastedImage>, image_counter: &mut usize) {
/// is acceptable in v1).
pub fn cleanup_temp_file(img: &PastedImage) {
if img.session_image_path.is_some() {
return; // already persisted to session dir, leave it
// already persisted to session dir, leave it
return;
}
if let Some(ref path) = img.staged_temp_path {
let _ = std::fs::remove_file(path);
}
}
// -------------------------------------------------------------------------
// Construction from file path
// -------------------------------------------------------------------------
/// Image file extensions recognized when a pasted path is checked.
///
@@ -1116,7 +1105,7 @@ pub enum DroppedPath {
/// Predicate order: cheap anchor/`file://` checks run first; the
/// (relatively) more expensive [`read_image_at_path`] file-read +
/// magic-byte sniff runs only for tokens that pass the gate. Bare
/// cwd-relative image filenames are intentionally NOT intercepted —
/// cwd-relative image filenames are deliberately NOT intercepted —
/// drag-and-drop / Finder-paste always emit absolute paths or
/// `file://` URLs, never `foo.png`-style relative refs.
///
@@ -1294,9 +1283,7 @@ pub fn try_read_image_from_path(text: &str) -> Option<PastedImage> {
}
}
// -------------------------------------------------------------------------
// Construction from clipboard data
// -------------------------------------------------------------------------
/// Build a `PastedImage` from raw clipboard [`ImageData`].
///
@@ -1317,9 +1304,7 @@ pub fn from_clipboard_data(data: &crate::clipboard::ImageData) -> PastedImage {
}
}
// -------------------------------------------------------------------------
// Session image persistence
// -------------------------------------------------------------------------
/// Persist image bytes into the session `images/` directory.
///
@@ -1398,11 +1383,9 @@ pub fn session_mermaid_dir(
Some(kigi_shared::session::session_dir(&info).join("mermaid"))
}
// -------------------------------------------------------------------------
// Image loading for send
// -------------------------------------------------------------------------
const MAX_SEND_BYTES: usize = 50_000_000; // 50 MB
const MAX_SEND_BYTES: usize = 50_000_000;
/// Load image bytes from a `PastedImage` (in-memory or from disk).
/// Returns `None` if the image cannot be loaded or exceeds [`MAX_SEND_BYTES`].
@@ -1449,9 +1432,7 @@ pub fn load_for_send(img: &PastedImage) -> Option<(Vec<u8>, String)> {
Some((raw_bytes, img.mime_type.clone()))
}
// -------------------------------------------------------------------------
// ACP content block construction
// -------------------------------------------------------------------------
/// Build ACP `ContentBlock` values from prompt text and attached images,
/// with an optional fallback that re-loads orphan
@@ -1614,7 +1595,8 @@ fn resolve_orphan_placeholders(
for ph in &placeholders {
if attached_numbers.contains(&ph.display_number) {
continue; // PastedImage already supplies these bytes.
// PastedImage already supplies these bytes.
continue;
}
match kigi_shared::placeholder_images::load_placeholder_image(&ph.path, allowed) {
Ok(loaded) => {
@@ -1707,9 +1689,7 @@ fn collapse_strip_seam(text: &mut String, start: usize, end: usize) {
}
}
// -------------------------------------------------------------------------
// Scrollback image references
// -------------------------------------------------------------------------
/// An image file referenced in scrollback content via `![alt](path)` markdown
/// or a bare absolute path. Validated on construction: path must exist, have a
@@ -1841,9 +1821,7 @@ pub fn extract_image_refs(text: &str) -> Vec<ScrollbackImageRef> {
refs
}
// -------------------------------------------------------------------------
// Scrollback video references
// -------------------------------------------------------------------------
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "webm", "mov", "avi", "mkv"];
@@ -1927,9 +1905,7 @@ pub fn extract_video_refs(text: &str) -> Vec<ScrollbackVideoRef> {
refs
}
// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
mod tests {
@@ -1979,7 +1955,7 @@ mod tests {
}
}
// ----- display_text ---------------------------------------------------
// display_text
#[test]
fn display_text_format() {
@@ -1988,7 +1964,7 @@ mod tests {
assert_eq!(display_text(10), "[Image #10]");
}
// ----- extension_for_mime ---------------------------------------------
// extension_for_mime
#[test]
fn extension_for_known_mimes() {
@@ -2120,7 +2096,7 @@ mod tests {
assert!(res.is_err(), "expected write to read-only dir to fail");
}
// ----- shell_unescape -------------------------------------------------
// shell_unescape
#[test]
fn shell_unescape_spaces() {
@@ -2153,7 +2129,7 @@ mod tests {
assert_eq!(shell_unescape(r"path\\name"), r"path\name");
}
// ----- shell_unescape / Windows-path round-trip ----------------------
// shell_unescape / Windows-path round-trip
//
// `\` is a path separator on Windows, not a shell escape. The
// unescape must skip Windows-looking inputs or it would collapse
@@ -2209,7 +2185,7 @@ mod tests {
);
}
// ----- try_read_image_from_path ----------------------------------------
// try_read_image_from_path
#[test]
fn try_read_image_with_escaped_parens() {
@@ -2246,8 +2222,7 @@ mod tests {
assert!(result.unwrap().source_path.is_some());
}
// ----- single-file resilience (drop with trailing whitespace / quotes /
// file:// URLs) ---------------------------------------------------
// single-file resilience: drop with trailing whitespace / quotes / file:// URLs
/// Writes a real PNG at `path`. Helper to keep the multi-file tests tidy.
fn write_png(path: &std::path::Path, w: u32, h: u32) {
@@ -2308,7 +2283,7 @@ mod tests {
assert!(try_read_image_from_path(&pasted).is_some());
}
// ----- file:// URL parsing -------------------------------------------
// file:// URL parsing
#[test]
fn try_read_image_file_url() {
@@ -2357,7 +2332,7 @@ mod tests {
assert!(try_read_image_from_path(&pasted).is_some());
}
// ----- multi-file drop -----------------------------------------------
// multi-file drop
/// Non-image paths are canonicalized before insertion.
fn canon(p: &std::path::Path) -> PathBuf {
@@ -2490,7 +2465,7 @@ mod tests {
);
}
// ----- negatives — must not auto-attach ------------------------------
// negatives — must not auto-attach
#[test]
fn free_prose_containing_slash_returns_empty() {
@@ -2527,7 +2502,7 @@ mod tests {
assert!(try_read_images_from_paste("line one\nline two").is_empty());
}
// ----- additional edge cases ----
// additional edge cases
#[test]
fn bash_mode_prefix_not_treated_as_image() {
@@ -2576,7 +2551,8 @@ mod tests {
fn newline_wins_space_inside_line_not_split() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let bc = dir.path().join("b.png c.png"); // a single file with a space in its name
// a single file with a space in its name
let bc = dir.path().join("b.png c.png");
let other = dir.path().join("d.png");
write_png(&a, 2, 2);
write_png(&bc, 2, 2);
@@ -2610,7 +2586,7 @@ mod tests {
assert!(try_read_image_from_path(&pasted).is_some());
}
// ----- file:// URL edge cases ----------------------------
// file:// URL edge cases
#[test]
fn file_url_with_localhost_host() {
@@ -2651,7 +2627,7 @@ mod tests {
assert!(try_read_image_from_path(&pasted).is_some());
}
// ----- multi-file space-separated mixed file:// + bare ---
// multi-file space-separated mixed file:// + bare
#[test]
fn multi_file_space_separated_file_url_then_bare() {
@@ -2777,7 +2753,7 @@ mod tests {
assert!(try_read_image_from_path(pasted).is_none());
}
// ----- try_read_dropped_paths -----------------------------------------
// try_read_dropped_paths
fn dropped_paths(text: &str) -> Vec<DroppedPath> {
try_read_dropped_paths(text)
@@ -3226,7 +3202,7 @@ mod tests {
/// pipeline when inserted as text. Reject these at parse time
/// so the prompt never sees them.
///
/// The gate is intentionally narrow (NUL, CR, LF) — TAB and
/// The gate is deliberately narrow (NUL, CR, LF) — TAB and
/// other low-control bytes are legal in Unix filenames and the
/// TUI's text path renders them fine.
#[test]
@@ -3250,7 +3226,7 @@ mod tests {
);
}
/// HEIC/HEIF/AVIF/ICO are intentionally NOT in `IMAGE_EXTENSIONS`
/// HEIC/HEIF/AVIF/ICO are deliberately NOT in `IMAGE_EXTENSIONS`
/// — the inline overlay doesn't render them, so we fall through
/// to NonImage path text instead of falsely promoting a chip.
#[test]
@@ -3271,7 +3247,7 @@ mod tests {
}
}
/// SVG is intentionally NOT in `IMAGE_EXTENSIONS` (XML/text
/// SVG is deliberately NOT in `IMAGE_EXTENSIONS` (XML/text
/// formats aren't sniffed as images and the inline overlay does
/// not render SVG). An `.svg` drop must fall through to NonImage
/// so the user gets a path string they can pass to the agent.
@@ -3402,7 +3378,7 @@ mod tests {
#[test]
fn try_read_images_from_paste_equals_image_filtered_dropped_paths() {
// `try_read_images_from_paste` is now a thin filter over
// `try_read_images_from_paste` is a thin filter over
// `try_read_dropped_paths`. Lock in the delegation invariant
// for several input shapes so a regression that diverges
// only in one shape (e.g. the empty-paste case) would still
@@ -3624,7 +3600,7 @@ mod tests {
}
}
// ----- reconcile ------------------------------------------------------
// reconcile
#[test]
fn reconcile_keeps_live_images() {
@@ -3668,7 +3644,7 @@ mod tests {
assert!(images.is_empty());
}
// ----- clear ----------------------------------------------------------
// clear
#[test]
fn clear_resets_images_and_counter() {
@@ -3681,7 +3657,7 @@ mod tests {
assert_eq!(counter, 0);
}
// ----- persist_to_session ------------------------------------------------
// persist_to_session
#[test]
fn persist_writes_file_and_clears_bytes() {
@@ -3750,12 +3726,13 @@ mod tests {
#[test]
fn persist_fails_without_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut img = make_image(1, 1); // encoded_bytes is None
// encoded_bytes is None
let mut img = make_image(1, 1);
let result = persist_to_session(&mut img, dir.path());
assert!(result.is_err());
}
// ----- persist_to_session path ownership --------------------------------
// persist_to_session path ownership
#[test]
fn persist_clipboard_image_keeps_source_path_none() {
@@ -3773,7 +3750,8 @@ mod tests {
dimensions: Some((100, 80)),
byte_len: png.len(),
encoded_bytes: Some(Arc::from(png)),
source_path: None, // clipboard paste — no original path
// clipboard paste — no original path
source_path: None,
staged_temp_path: None,
session_image_path: None,
preview: PromptImagePreview::default(),
@@ -3826,7 +3804,7 @@ mod tests {
);
}
// ----- from_clipboard_data -----------------------------------------------
// from_clipboard_data
#[test]
fn from_clipboard_data_populates_fields() {
@@ -3875,7 +3853,7 @@ mod tests {
assert_eq!(img.encoded_bytes.as_deref(), Some(data.data.as_slice()));
}
// ----- test PNG helper --------------------------------------------------
// test PNG helper
/// Generate a valid minimal PNG of the given dimensions.
fn make_test_png(width: u32, height: u32) -> Vec<u8> {
@@ -3918,7 +3896,7 @@ mod tests {
}
}
// ----- load_for_send ------------------------------------------------
// load_for_send
#[test]
fn load_small_image_passes_through() {
@@ -3948,7 +3926,7 @@ mod tests {
mime_type: "image/png".into(),
dimensions: Some((50, 50)),
byte_len: png.len(),
encoded_bytes: None, // bytes released
encoded_bytes: None,
source_path: None,
staged_temp_path: None,
session_image_path: Some(path),
@@ -3960,11 +3938,12 @@ mod tests {
#[test]
fn load_returns_none_for_missing_data() {
let img = make_image(1, 1); // no bytes, no file
// no bytes, no file
let img = make_image(1, 1);
assert!(load_for_send(&img).is_none());
}
// ----- build_content_blocks_with_workspace --------------------------------
// build_content_blocks_with_workspace
fn build_blocks_no_workspace(
text: String,
@@ -4108,7 +4087,8 @@ mod tests {
#[test]
fn build_blocks_skips_missing_image() {
let img = make_image(1, 1); // no bytes, no file path
// no bytes, no file path
let img = make_image(1, 1);
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
// Only the text block; image was skipped.
assert_eq!(blocks.len(), 1);
@@ -4116,14 +4096,14 @@ mod tests {
#[test]
fn build_blocks_one_bad_one_good() {
let bad = make_image(1, 1); // no bytes
let bad = make_image(1, 1);
let good = make_real_image(50, 50);
let blocks = build_blocks_no_workspace("text".into(), vec![bad, good]);
// Text + 1 good image; bad image skipped.
assert_eq!(blocks.len(), 2);
}
// ----- Orphan placeholder fallback ----------------------------------
// Orphan placeholder fallback
//
// These tests go through `build_content_blocks_with_prefixes` with
// an explicit hermetic prefix list, so they do NOT read the
@@ -4165,7 +4145,7 @@ mod tests {
.decode(&ic.data)
.expect("data must be valid base64");
assert_eq!(decoded, on_disk);
// Placeholder anchor stays but the path is now stripped — the
// Placeholder anchor stays but the path is stripped — the
// image is already attached inline, so the model has no reason
// to call `Read` on the path (and the path component would
// tempt it to). The bracketed `[Image #N]` form preserves the
@@ -4324,7 +4304,7 @@ mod tests {
);
}
// ----- TUI aggregate-cap injectable variant + tests -----------------
// TUI aggregate-cap injectable variant + tests
//
// Mirrors the server-side
// `recover_orphan_placeholders_with_prefixes_and_caps` tests so a
@@ -4422,7 +4402,7 @@ mod tests {
/// **Text-side contract.** Aggregate-cap breach is a `break`
/// path in `resolve_orphan_placeholders`, not a per-image
/// `Err` path. Only `Err`-path failures strip the placeholder
/// text; cap-breach intentionally **leaves the placeholder
/// text; cap-breach deliberately **leaves the placeholder
/// text intact** because the load itself succeeded (the file
/// is valid, just doesn't fit in the budget). The test pins
/// both halves of this contract: no image block AND
@@ -4452,7 +4432,7 @@ mod tests {
// breach (cap-breach is a `break` path, not a load `Err`).
// Pinning the preservation half of the contract.
//
// Phase 2 path-strip update: the bracketed anchor
// Phase 2 path-strip behaviour: the bracketed anchor
// `[Image #N]` survives, but the `: <path>` component is
// stripped uniformly across every surviving placeholder. The
// model can still see *where* in the prose the image was
@@ -4470,7 +4450,7 @@ mod tests {
);
}
// ----- T8: cleanup and lifecycle edge cases ------------------------------
// T8: cleanup and lifecycle edge cases
#[test]
fn clear_deletes_staged_temp_file() {
@@ -4488,7 +4468,8 @@ mod tests {
encoded_bytes: None,
source_path: None,
staged_temp_path: Some(tmp_path.clone()),
session_image_path: None, // not yet persisted to session
// not yet persisted to session
session_image_path: None,
preview: PromptImagePreview::default(),
}];
let mut counter = 1;
@@ -4578,7 +4559,7 @@ mod tests {
);
}
// ----- ScrollbackImageRef ------------------------------------------------
// ScrollbackImageRef
#[test]
fn scrollback_ref_from_valid_image_path() {
@@ -4634,7 +4615,7 @@ mod tests {
assert!(ScrollbackImageRef::from_path("/nonexistent/image.png").is_none());
}
// ----- extract_image_refs ------------------------------------------------
// extract_image_refs
#[test]
fn extract_markdown_image_ref() {
@@ -4714,7 +4695,7 @@ mod tests {
assert!(refs.is_empty());
}
// ----- open_from_path ----------------------------------------------------
// open_from_path
#[test]
fn open_from_path_valid_image() {
@@ -4751,7 +4732,7 @@ mod tests {
);
}
// ----- open_from_path_deferred -------------------------------------------
// open_from_path_deferred
#[test]
fn deferred_open_starts_in_loading_state() {
@@ -59,7 +59,7 @@ pub fn indexed_to_rgb(index: u8) -> (u8, u8, u8) {
/// ramp (232255), returning whichever has the smallest squared Euclidean
/// distance.
pub fn nearest_indexed(r: u8, g: u8, b: u8) -> u8 {
// --- nearest in the 6×6×6 color cube (16231) ---
// nearest in the 6×6×6 color cube (16231)
let ri = nearest_cube_channel(r);
let gi = nearest_cube_channel(g);
let bi = nearest_cube_channel(b);
@@ -73,7 +73,7 @@ pub fn nearest_indexed(r: u8, g: u8, b: u8) -> u8 {
CUBE_VALUES[bi as usize],
);
// --- nearest in the grayscale ramp (232255) ---
// nearest in the grayscale ramp (232255)
// Ramp values: 8, 18, 28, …, 238 (24 entries)
let lum = (r as u16 + g as u16 + b as u16) / 3;
let gray_step = if lum <= 3 {
@@ -131,7 +131,7 @@ fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
///
/// Useful when downstream code must produce RGB for *every* color value
/// — e.g. progress-bar gradients that lerp across named breakpoints, or
/// OSC 12 cursor-color updates that must emit an RGB triple regardless
/// OSC 12 cursor-color writes that must emit an RGB triple regardless
/// of terminal color depth.
///
/// Named-color RGB matches the xterm 16-color palette used by
@@ -166,11 +166,10 @@ pub fn resolve_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
/// Blend a single color channel: lerp from base toward original based on opacity.
///
/// - `opacity = 0.0`: returns `base` (fully faded)
/// - `opacity = 1.0`: returns `original` (no change)
/// - `opacity = 1.0`: returns `original` (Unchanged)
#[inline]
pub fn blend_channel(base: u8, original: u8, opacity: f32) -> u8 {
// result = base + (original - base) * opacity
// = base * (1 - opacity) + original * opacity
let result = base as f32 * (1.0 - opacity) + original as f32 * opacity;
result.round() as u8
}
@@ -178,7 +177,7 @@ pub fn blend_channel(base: u8, original: u8, opacity: f32) -> u8 {
/// Blend a color toward a base color based on opacity.
///
/// - `opacity = 0.0`: returns `base` (fully faded)
/// - `opacity = 1.0`: returns `original` (no change)
/// - `opacity = 1.0`: returns `original` (Unchanged)
///
/// Supports both `Color::Rgb` and `Color::Indexed` colors (indexed colors are
/// converted to their RGB equivalents for blending). When either input is
@@ -212,9 +211,9 @@ pub fn blend_color(base: Color, original: Color, opacity: f32) -> Option<Color>
/// its colors toward the background.
///
/// - `opacity = 0.0`: fully faded to base color
/// - `opacity = 1.0`: no change (original colors)
/// - `opacity = 1.0`: Unchanged (original colors)
///
/// Named ANSI colors are left unchanged.
/// Named ANSI colors are left `unchanged`.
pub fn blend_line(line: Line<'static>, base: Color, opacity: f32) -> Line<'static> {
let blended_spans: Vec<Span<'static>> = line
.spans
@@ -239,9 +238,9 @@ pub fn blend_line(line: Line<'static>, base: Color, opacity: f32) -> Line<'stati
/// explicitly colored text.
///
/// - `opacity = 0.0`: fully faded to base color
/// - `opacity = 1.0`: no change (original colors)
/// - `opacity = 1.0`: Unchanged (original colors)
///
/// Named ANSI colors are left unchanged.
/// Named ANSI colors are left `unchanged`.
pub fn blend_line_with_default(
line: Line<'static>,
base: Color,
@@ -269,10 +268,10 @@ pub fn blend_line_with_default(
/// This blends both foreground and background colors of each cell toward
/// `base_color` based on `opacity`:
/// - `opacity = 0.0`: fully faded (cells become base_color)
/// - `opacity = 1.0`: no change
/// - `opacity = 1.0`: Unchanged
///
/// Both RGB and Indexed colors are blended; named ANSI colors (Color::Red, etc.)
/// are left unchanged since their RGB values are terminal-dependent.
/// are left `unchanged` since their RGB values are terminal-dependent.
pub fn fade_region(buf: &mut Buffer, area: Rect, base_color: Color, opacity: f32) {
blend_area(
buf,
@@ -285,10 +284,10 @@ pub fn fade_region(buf: &mut Buffer, area: Rect, base_color: Color, opacity: f32
/// Blend fg and/or bg of every cell in an area toward target colors.
///
/// Each parameter is `Option<(target, opacity)>`:
/// - `None`: leave that channel unchanged
/// - `None`: leave that channel `unchanged`
/// - `Some((target, opacity))`: blend toward `target` at `opacity`
/// - `opacity = 0.0`: fully target (original gone)
/// - `opacity = 1.0`: no change (original kept)
/// - `opacity = 1.0`: Unchanged (original kept)
///
/// Both RGB and Indexed colors are blended; named ANSI color cells are skipped.
pub fn blend_area(
@@ -383,7 +382,8 @@ mod tests {
// opacity = 0.5: halfway between
assert_eq!(blend_channel(0, 100, 0.5), 50);
assert_eq!(blend_channel(100, 200, 0.5), 150);
assert_eq!(blend_channel(0, 255, 0.5), 128); // 127.5 rounds to 128
// 127.5 rounds to 128
assert_eq!(blend_channel(0, 255, 0.5), 128);
}
#[test]
@@ -403,7 +403,7 @@ mod tests {
let faded = blend_color(base, original, 0.0);
assert_eq!(faded, Some(Color::Rgb(0, 0, 0)));
// No change
// Unchanged
let unchanged = blend_color(base, original, 1.0);
assert_eq!(unchanged, Some(Color::Rgb(100, 150, 200)));
@@ -415,8 +415,10 @@ mod tests {
#[test]
fn test_blend_color_indexed_returns_indexed() {
// Both indexed → result is indexed (quantized back to 256-color palette)
let base = Color::Indexed(232); // near-black (8, 8, 8)
let original = Color::Indexed(255); // near-white (238, 238, 238)
// near-black (8, 8, 8)
let base = Color::Indexed(232);
// near-white (238, 238, 238)
let original = Color::Indexed(255);
let half = blend_color(base, original, 0.5).unwrap();
assert!(matches!(half, Color::Indexed(_)));
@@ -433,7 +435,8 @@ mod tests {
#[test]
fn test_blend_color_mixed_returns_indexed() {
let rgb = Color::Rgb(100, 100, 100);
let indexed = Color::Indexed(5); // magenta (128, 0, 128)
// magenta (128, 0, 128)
let indexed = Color::Indexed(5);
// Mixed: indexed base + rgb original → Indexed result (quantized)
let result = blend_color(indexed, rgb, 0.5);
@@ -483,8 +486,8 @@ mod tests {
// Check cells are faded
if let Some(cell) = buf.cell((0, 0)) {
assert_eq!(cell.fg, Color::Rgb(100, 100, 100)); // 200 * 0.5
assert_eq!(cell.bg, Color::Rgb(25, 25, 25)); // 50 * 0.5
assert_eq!(cell.fg, Color::Rgb(100, 100, 100));
assert_eq!(cell.bg, Color::Rgb(25, 25, 25));
}
}
@@ -507,14 +510,14 @@ mod tests {
// Only fade a 2x2 region in the middle
fade_region(&mut buf, Rect::new(1, 1, 2, 2), base, 0.0);
// Corner should be unchanged
// Corner should be `unchanged`
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(100, 100, 100));
// Middle should be fully faded
assert_eq!(buf.cell((1, 1)).unwrap().fg, Color::Rgb(0, 0, 0));
assert_eq!(buf.cell((2, 2)).unwrap().fg, Color::Rgb(0, 0, 0));
// Other corner unchanged
// Other corner `unchanged`
assert_eq!(buf.cell((3, 3)).unwrap().fg, Color::Rgb(100, 100, 100));
}
@@ -535,7 +538,7 @@ mod tests {
// fg blended to 50%
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(100, 50, 0));
// bg unchanged
// bg `unchanged`
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Rgb(10, 10, 10));
}
@@ -554,7 +557,7 @@ mod tests {
let target = Color::Rgb(0, 0, 0);
blend_area(&mut buf, Rect::new(0, 0, 2, 1), None, Some((target, 0.5)));
// fg unchanged
// fg `unchanged`
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(200, 200, 200));
// bg blended to 50%
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Rgb(50, 50, 50));
@@ -611,7 +614,8 @@ mod tests {
fn test_blend_area_named_color_skipped() {
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
if let Some(cell) = buf.cell_mut((0, 0)) {
cell.set_fg(Color::Red); // named color — blend_color returns None
// named color — blend_color returns None
cell.set_fg(Color::Red);
cell.set_bg(Color::Red);
}
@@ -622,7 +626,7 @@ mod tests {
Some((Color::Rgb(0, 0, 0), 0.5)),
);
// Named colors should be unchanged (blend_color returns None for them)
// Named colors should be `unchanged` (blend_color returns None for them)
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Red);
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Red);
}
@@ -631,12 +635,14 @@ mod tests {
fn test_blend_area_indexed_colors_blended() {
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
if let Some(cell) = buf.cell_mut((0, 0)) {
cell.set_fg(Color::Indexed(255)); // near-white grayscale
// near-white grayscale
cell.set_fg(Color::Indexed(255));
cell.set_bg(Color::Indexed(255));
}
// Blend toward black (indexed 232 = #080808, but we use indexed 16 = #000000)
let target = Color::Indexed(16); // black in the color cube
// black in the color cube
let target = Color::Indexed(16);
blend_area(
&mut buf,
Rect::new(0, 0, 1, 1),
@@ -368,7 +368,7 @@ pub fn draw_frame(
#[cfg(test)]
mod tests {
use super::*;
/// An unchanged frame must emit zero bytes to the PTY.
/// An `unchanged` frame must emit zero bytes to the PTY.
#[test]
fn idle_frame_emits_zero_bytes() {
use ratatui::backend::CrosstermBackend;
@@ -12,8 +12,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Widget};
use crate::gboom::GboomHud;
use crate::render::safe_buf::SafeBuf;
/// Render the GBOOM popup chrome. Returns the popup `Rect`,
/// or `None` if the area is too small to play in.
/// Returns the popup `Rect`, or `None` when the area is too small to play in.
pub fn render_gboom_overlay(
buf: &mut Buffer,
area: Rect,
@@ -28,7 +27,6 @@ pub fn render_gboom_overlay(
crate::render::color::dim_area(buf, area, bg, 0.5);
// 90% centered popup, like the video viewer.
let popup_width = ((area.width as u32 * 90) / 100)
.max(30)
.min(area.width as u32) as u16;
@@ -49,7 +47,6 @@ pub fn render_gboom_overlay(
.style(Style::default().bg(bg))
.render(popup_rect, buf);
// Title centered in the top border, in the iconic logo red.
let title = " GBOOM ";
let [r, g, b] = crate::gboom::GBOOM_RED;
let title_style = Style::default()
@@ -60,14 +57,13 @@ pub fn render_gboom_overlay(
let tx = popup_rect.x + (popup_rect.width.saturating_sub(tw)) / 2;
buf.set_span_safe(tx, popup_rect.y, &Span::styled(title, title_style), tw);
// HUD on the bottom border row.
render_hud_bar(buf, popup_rect, hud, border_fg, bg);
Some(popup_rect)
}
/// Render the HUD on the popup's bottom border row:
/// `HP 100 · KILLS 0/8` left, controls hint right.
/// Overwrites the popup's bottom border row: `HP 100 · KILLS 0/8` on the left,
/// controls hint on the right.
fn render_hud_bar(buf: &mut Buffer, popup_rect: Rect, hud: &GboomHud, dim_fg: Color, bg: Color) {
let bar_y = popup_rect.y + popup_rect.height.saturating_sub(1);
let inner_width = popup_rect.width.saturating_sub(2) as usize;
@@ -75,8 +71,7 @@ fn render_hud_bar(buf: &mut Buffer, popup_rect: Rect, hud: &GboomHud, dim_fg: Co
return;
}
// Health-bar semantics: green when comfortable, amber when hurting,
// GBOOM red when critical.
// Green when comfortable, amber when hurting, GBOOM red when critical.
let hp_color = if hud.hp > 60 {
Color::Rgb(126, 200, 96)
} else if hud.hp > 30 {
@@ -70,8 +70,8 @@ pub fn paint_match_highlights(
}
}
/// Apply the terminal's REVERSED attribute so the fg/bg swap is native and
/// respects the user's theme.
/// REVERSED leaves the fg/bg swap to the terminal, so highlights follow the
/// user's theme instead of hardcoded colors.
fn invert_cell(cell: &mut ratatui::buffer::Cell) {
cell.modifier.insert(ratatui::style::Modifier::REVERSED);
}
@@ -17,6 +17,7 @@ pub(super) fn paint_path_line(
bg: Color,
) {
let raw = path.display().to_string();
// The 6 reserved columns are the "Path: " prefix.
let label = format!(
"Path: {}",
truncate_path_for_overlay(&raw, width.saturating_sub(6) as usize)
@@ -109,7 +109,7 @@ pub fn truncate_str(s: &str, max_width: usize) -> String {
/// exhausted mid-span, that span is truncated and `…` is appended. Spans
/// beyond the budget are dropped. All styles are preserved.
///
/// Returns the line unchanged if it already fits.
/// Returns the line `unchanged` if it already fits.
pub fn truncate_line(line: Line<'static>, max_width: usize) -> Line<'static> {
if max_width == 0 {
return Line::from(vec![]);
@@ -152,7 +152,7 @@ pub fn truncate_line(line: Line<'static>, max_width: usize) -> Line<'static> {
/// Clip or pad a styled `Line` to exactly `width` display columns.
///
/// Wider lines are clipped on grapheme boundaries (a multi-`char` grapheme like
/// `⚠\u{FE0F}` is never split) with no ellipsis; narrower lines are padded with
/// `⚠\u{FE0F}` is never split) with no ellipsis; narrower lines are `padded` with
/// trailing spaces. This keeps a rendered row "self-owning" — the app writes a
/// real cell in every column, so a terminal drawing a glyph wider than the app
/// measured cannot strand a stale cell past the row (the markdown-table ghost
@@ -346,10 +346,11 @@ mod tests {
let s = "hello — world";
let result = truncate_str(s, 8);
assert!(result.ends_with('…'));
assert!(result.len() <= 12); // safe byte length
// safe byte length
assert!(result.len() <= 12);
}
// ── truncate_line tests ─────────────────────────────────────────
// truncate_line tests
#[test]
fn truncate_line_fits() {
@@ -395,7 +396,7 @@ mod tests {
assert!(result.spans.is_empty());
}
// ── fit_line_to_width tests ─────────────────────────────────────
// fit_line_to_width tests
fn line_text(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
@@ -438,7 +439,7 @@ mod tests {
#[test]
fn fit_line_clips_grapheme_straddle_in_later_span() {
// The straddle happens in a later span: keep "ab", then 1 col left →
// ⚠️ (width 2) won't fit → dropped whole and padded.
// ⚠️ (width 2) won't fit → dropped whole and `padded`.
let line = Line::from(vec![Span::raw("ab"), Span::raw("\u{26A0}\u{FE0F}cd")]);
let out = fit_line_to_width(line, 3);
assert_eq!(line_text(&out).width(), 3);
@@ -502,7 +503,7 @@ mod tests {
);
}
// ── cascade_truncate tests ────────────────────────────────────
// cascade_truncate tests
#[test]
fn cascade_truncate_all_fit() {
@@ -1,6 +1,4 @@
//! Low-level rendering utilities.
//!
//! Generic rendering primitives used by the scrollback and viewport.
//! Low-level rendering primitives shared by the scrollback and viewport.
pub mod color;
pub mod draw;
pub mod gboom_overlay;
@@ -48,7 +48,8 @@ impl LinkOverlay {
link.col_end
);
if link.col_start > link.col_end {
return; // Silently skip inverted ranges in release mode.
// Silently skip inverted ranges in release mode.
return;
}
self.links.push(link);
}
@@ -541,7 +542,7 @@ mod tests {
scan_lines_for_url_overlays(rows.into_iter(), content_x, media_paths, overlay);
}
// ── local_link_to_file_url ──
// local_link_to_file_url
#[test]
fn local_link_relative_resolves_to_generated_media() {
@@ -592,7 +593,7 @@ mod tests {
assert!(local_link_to_file_url("../images/1.jpg", &media).is_none());
}
// ── tool_path_file_url ──
// tool_path_file_url
#[test]
fn tool_path_file_url_resolves_relative_against_cwd() {
@@ -637,7 +638,7 @@ mod tests {
);
}
// ── LinkOverlay ──
// LinkOverlay
#[test]
fn overlay_empty_by_default() {
@@ -661,7 +662,7 @@ mod tests {
assert_eq!(overlay.links()[0].screen_row, 5);
}
// ── scan_lines_for_url_overlays ──
// scan_lines_for_url_overlays
use ratatui::text::{Line as RLine, Span as RSpan};
@@ -690,7 +691,8 @@ mod tests {
assert_eq!(link.screen_row, 5);
// "See " = 4 display cols, content_x = 2
assert_eq!(link.col_start, 6);
assert_eq!(link.col_end, 6 + 19); // "https://example.com" = 19 chars
// "https://example.com" = 19 chars
assert_eq!(link.col_end, 6 + 19);
assert_eq!(link.id, None);
}
@@ -796,7 +798,7 @@ mod tests {
assert_eq!(overlay.links()[0].col_end, 10 + 16);
}
// ── File path detection ──
// File path detection
#[test]
fn scan_detects_absolute_file_path() {
@@ -875,7 +877,7 @@ mod tests {
#[test]
fn scan_detects_media_path_soft_wrapped_across_rows() {
// Regression: media-tool output prose wraps the long session path
// across visual rows (`joiner: Some("")` mid-word break). Previously
// across visual rows (`joiner: Some("")` mid-word break). earlier
// each row was scanned in isolation, so only the `/Users/alice`
// fragment on the first row matched and became clickable.
let row0 =
@@ -1162,7 +1164,7 @@ mod tests {
&*overlay.links()[0].url,
"file:///tmp/release/Demo%20App.app"
);
assert_eq!(overlay.links()[0].col_start, 5); // "open "
assert_eq!(overlay.links()[0].col_start, 5);
assert_eq!(
overlay.links()[0].col_end,
5 + UnicodeWidthStr::width(path) as u16
@@ -1184,7 +1186,7 @@ mod tests {
assert_eq!(overlay.links()[0].col_end, 4 + 12);
}
// ── Home-relative (`~/`) path detection ──
// Home-relative (`~/`) path detection
#[test]
fn scan_detects_tilde_file_path() {
@@ -1277,7 +1279,7 @@ mod tests {
id: None,
});
assert!(overlay.overlaps(5, 10, 20));
assert!(!overlay.overlaps(6, 10, 20)); // different row
assert!(!overlay.overlaps(6, 10, 20));
}
#[test]
@@ -1290,10 +1292,11 @@ mod tests {
url: Arc::from("https://a.example"),
id: None,
});
assert!(overlay.overlaps(0, 15, 25)); // right overlap
assert!(overlay.overlaps(0, 5, 15)); // left overlap
assert!(!overlay.overlaps(0, 20, 30)); // adjacent, no overlap
assert!(!overlay.overlaps(0, 0, 10)); // adjacent left
assert!(overlay.overlaps(0, 15, 25));
assert!(overlay.overlaps(0, 5, 15));
// adjacent, no overlap
assert!(!overlay.overlaps(0, 20, 30));
assert!(!overlay.overlaps(0, 0, 10));
}
#[test]
@@ -17,9 +17,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Clear, Widget};
use super::line_utils::{truncate_line, truncate_str};
use super::safe_buf::SafeBuf;
// ---------------------------------------------------------------------------
// PreviewStyle — configurable colors
// ---------------------------------------------------------------------------
/// Visual styling for the preview overlay.
#[derive(Debug, Clone, Copy)]
@@ -43,9 +41,7 @@ impl PreviewStyle {
}
}
// ---------------------------------------------------------------------------
// PreviewConfig — layout configuration
// ---------------------------------------------------------------------------
/// Layout configuration for the preview overlay.
#[derive(Debug, Clone)]
@@ -89,9 +85,7 @@ impl Default for PreviewConfig {
}
}
// ---------------------------------------------------------------------------
// render_preview_overlay — main rendering function
// ---------------------------------------------------------------------------
/// Render a multiline preview overlay.
///
@@ -129,7 +123,8 @@ pub fn render_preview_overlay(
// Calculate content layout
let needs_dots = total > config.preview_lines * 2;
let content_lines: usize = if needs_dots {
config.preview_lines * 2 + 1 // top + dots + bottom
// top + dots + bottom
config.preview_lines * 2 + 1
} else {
total
};
@@ -255,7 +250,7 @@ fn render_line(buf: &mut Buffer, x: u16, y: u16, width: u16, line: &str, style:
}
/// Paint the hint into the bottom border row, left-aligned after the
/// corner and one dash, padded with a space on each side so the text
/// corner and one dash, `padded` with a space on each side so the text
/// stands off the dashes: `╰─ enter to expand ────╯`. The corners and
/// one dash per side are never overwritten. Skipped entirely when the
/// box is too narrow for readable text.
@@ -283,9 +278,7 @@ fn render_border_hint(buf: &mut Buffer, box_area: Rect, hint: &Line<'static>, bg
buf.set_line_safe(box_area.x + 2, y, &Line::from(spans), box_area.width - 4);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
@@ -293,9 +286,12 @@ mod tests {
fn test_style() -> PreviewStyle {
PreviewStyle::new(
Color::Indexed(234), // grayscale 28 — dark bg
Color::Indexed(189), // (215,215,255) — light text
Color::Indexed(60), // (95,95,135) dim border
// grayscale 28 — dark bg
Color::Indexed(234),
// (215,215,255) — light text
Color::Indexed(189),
// (95,95,135) — dim border
Color::Indexed(60),
)
}
@@ -317,7 +313,8 @@ mod tests {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 3));
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 10, 3), // below min_height=5
// below min_height=5
Rect::new(0, 0, 10, 3),
"hello\nworld",
test_style(),
PreviewConfig::default(),
@@ -435,7 +432,7 @@ mod tests {
);
assert!(result.is_some());
let rect = result.unwrap();
assert_eq!(rect.width, 50); // 100 * 0.5 = 50
assert_eq!(rect.width, 50);
}
#[test]
@@ -57,9 +57,7 @@ impl<'a> From<Box<dyn Renderable + 'a>> for RenderableItem<'a> {
}
}
// ============================================================================
// Standard Implementations
// ============================================================================
/// Unit type renders as nothing (0 height).
impl Renderable for () {
@@ -6,25 +6,19 @@
//! a crash.
//!
//! This extension trait provides `set_line_safe` / `set_span_safe` /
//! `set_string_safe` that silently skip the write when `y` is outside the
//! buffer — trading a single missed frame for a panic-free resize.
//! `set_string_safe`, which skip the write when the target row lies outside
//! the buffer or `x` is past its right edge — trading a single missed frame
//! for a panic-free resize.
use ratatui::buffer::Buffer;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
/// Extension trait for bounds-checked buffer writes.
pub trait SafeBuf {
/// Like `Buffer::set_line` but returns immediately when `y` is outside
/// the buffer area.
fn set_line_safe(&mut self, x: u16, y: u16, line: &Line<'_>, width: u16);
/// Like `Buffer::set_span` but returns immediately when `y` is outside
/// the buffer area.
fn set_span_safe(&mut self, x: u16, y: u16, span: &Span<'_>, width: u16);
/// Like `Buffer::set_string` but returns immediately when `y` is outside
/// the buffer area.
fn set_string_safe<S: AsRef<str>>(&mut self, x: u16, y: u16, string: S, style: Style);
}
@@ -76,7 +76,7 @@ pub const SCROLLBAR_TOTAL_COLS: u16 = SCROLLBAR_GAP_COLS + SCROLLBAR_TRACK_COLS;
/// Layout:
/// - `content_area`: original area minus [`SCROLLBAR_TOTAL_COLS`] on the right
/// - `scrollbar_area`: the last column of the original area (1 cell wide)
/// - The column between them is the "gap" (left intentionally blank)
/// - The column between them is the "gap" (left deliberately blank)
///
/// Returns `(content_area, None)` when the terminal is too narrow.
///
@@ -126,7 +126,8 @@ pub fn needs_scrollbar(total_lines: u16, viewport_lines: u16) -> bool {
}
/// Whether the view is at the bottom (following mode position).
#[allow(dead_code)] // Useful helper, kept for future use
// Useful helper, kept for future use
#[allow(dead_code)]
pub fn is_at_bottom(total_lines: u16, viewport_lines: u16, offset: u16) -> bool {
let max_offset = total_lines.saturating_sub(viewport_lines);
offset >= max_offset
@@ -384,7 +385,8 @@ mod tests {
// Content overflows (20 > 10) - should reserve scrollbar space
let (content, scrollbar) = maybe_split_for_scrollbar(area, 20);
assert_eq!(content.width, 38); // Reduced by 2 for gap + scrollbar track
// Reduced by 2 for gap + scrollbar track
assert_eq!(content.width, 38);
assert!(scrollbar.is_some());
}
@@ -394,24 +396,29 @@ mod tests {
// Content fits (5 <= 10) - should give full width to content
let (content, scrollbar) = maybe_split_for_scrollbar(area, 5);
assert_eq!(content.width, 40); // Full width
assert_eq!(content.width, 40);
assert!(scrollbar.is_none());
}
#[test]
fn test_needs_scrollbar() {
assert!(needs_scrollbar(100, 10)); // Content > viewport
assert!(!needs_scrollbar(10, 10)); // Content == viewport
assert!(!needs_scrollbar(5, 10)); // Content < viewport
// Content > viewport
assert!(needs_scrollbar(100, 10));
// Content == viewport
assert!(!needs_scrollbar(10, 10));
// Content < viewport
assert!(!needs_scrollbar(5, 10));
}
#[test]
fn test_is_at_bottom() {
// total=100, viewport=10 -> max_offset=90
assert!(is_at_bottom(100, 10, 90)); // At bottom
assert!(is_at_bottom(100, 10, 95)); // Past bottom (clamped)
assert!(!is_at_bottom(100, 10, 89)); // One line above bottom
assert!(!is_at_bottom(100, 10, 0)); // At top
assert!(is_at_bottom(100, 10, 90));
// Past bottom (clamped)
assert!(is_at_bottom(100, 10, 95));
// One line above bottom
assert!(!is_at_bottom(100, 10, 89));
assert!(!is_at_bottom(100, 10, 0));
}
#[test]
@@ -258,7 +258,6 @@ fn first_param(params: &Params, default: u16) -> u16 {
}
}
/// Map a 0-7 ANSI color index to a named ratatui color.
fn ansi16(n: u16) -> Color {
match n {
0 => Color::Black,
@@ -272,7 +271,6 @@ fn ansi16(n: u16) -> Color {
}
}
/// Map a 0-7 bright ANSI color index to a named ratatui color.
fn ansi16_bright(n: u16) -> Color {
match n {
0 => Color::DarkGray,
@@ -7,7 +7,6 @@ use unicode_width::UnicodeWidthStr;
use super::line_utils::truncate_str;
/// Read/Edit tool-header path paint surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolPathSurface {
/// Basename only.
@@ -183,7 +182,6 @@ pub fn path_basename(path: &str, budget: usize) -> String {
truncate_str(name, budget)
}
/// Compatibility formatter: compact basename with `Some(width)`, else stored path.
pub fn path_for_tool_header(path: &str, width: Option<usize>, reserved: usize) -> String {
match width {
Some(width) => path_basename(path, width.saturating_sub(reserved)),
@@ -191,7 +189,6 @@ pub fn path_for_tool_header(path: &str, width: Option<usize>, reserved: usize) -
}
}
/// Path text for a Read/Edit tool-header surface.
pub fn path_for_tool_surface(
path: &str,
surface: ToolPathSurface,
@@ -12,8 +12,6 @@ use ratatui::widgets::{Block, BorderType, Borders, Widget};
use crate::prompt_images::VideoViewerState;
use crate::render::safe_buf::SafeBuf;
/// Render the video viewer popup chrome. Returns the popup `Rect`,
/// or `None` if the area is too small.
pub fn render_video_overlay(
buf: &mut Buffer,
area: Rect,
@@ -28,7 +26,6 @@ pub fn render_video_overlay(
crate::render::color::dim_area(buf, area, bg, 0.5);
// 90% centered popup.
let popup_width = ((area.width as u32 * 90) / 100)
.max(28)
.min(area.width as u32) as u16;
@@ -49,7 +46,6 @@ pub fn render_video_overlay(
.style(Style::default().bg(bg))
.render(popup_rect, buf);
// Title centered in top border.
let title = match viewer.title {
Some(ref name) => format!(
" {} ({}\u{00d7}{}) ",
@@ -68,13 +64,11 @@ pub fn render_video_overlay(
let tx = popup_rect.x + (popup_rect.width.saturating_sub(tw)) / 2;
buf.set_span_safe(tx, popup_rect.y, &Span::styled(&title, title_style), tw);
// Progress bar on the bottom border row.
render_progress_bar(buf, popup_rect, viewer, text_fg, border_fg, bg);
Some(popup_rect)
}
/// Render the progress bar on the popup's bottom border row.
fn render_progress_bar(
buf: &mut Buffer,
popup_rect: Rect,
@@ -114,7 +114,8 @@ pub(crate) fn byte_offset_to_display_col(text: &str, byte_offset: usize) -> usiz
/// forward. If a future change to the wrapping pipeline breaks this
/// invariant, consider switching to a two-stage approach that mirrors
/// `word_wrap_line_with_joiners` exactly.
#[allow(clippy::single_range_in_vec_init)] // intentional: single range = full text, no wrapping
// intentional: single range = full text, no wrapping
#[allow(clippy::single_range_in_vec_init)]
pub fn wrap_byte_ranges_matching(text: &str, width: usize) -> Vec<Range<usize>> {
if width == 0 || text.is_empty() {
return vec![0..text.len()];
@@ -362,7 +363,7 @@ fn is_table_line(line: &Line<'_>) -> bool {
/// this wrap layer re-injecting the prefix spans (with their styles) on
/// continuation rows, so keep the two shapes in agreement.
fn blockquote_prefix_len(flat: &str) -> usize {
const BAR_BYTES: usize = '\u{2502}'.len_utf8(); // 3
const BAR_BYTES: usize = '\u{2502}'.len_utf8();
let mut len = 0;
let mut chars = flat.chars();
while let Some('\u{2502}') = chars.next() {
@@ -1187,14 +1188,15 @@ mod tests {
}
}
// -- byte_range_to_row_cols tests -----------------------------------------
// byte_range_to_row_cols tests
#[test]
fn highlight_single_row_match() {
// "hello world" on one row (no wrapping).
let text = "hello world";
let ranges = vec![0..11]; // one row, full text
let segments = byte_range_to_row_cols(text, &ranges, 6..11); // "world"
// one row, full text
let ranges = vec![0..11];
let segments = byte_range_to_row_cols(text, &ranges, 6..11);
assert_eq!(
segments,
vec![HighlightSegment {
@@ -1306,8 +1308,9 @@ mod tests {
// Byte layout: a(1) b(1) —(3) c(1) d(1) = 7 bytes total.
// Display: a(0) b(1) —(2) c(3) d(4) = 5 display columns.
let text = "ab\u{2014}cd";
assert_eq!(text.len(), 7); // 2 + 3 + 2 bytes
let ranges = vec![0..7]; // one row
// 2 + 3 + 2 bytes
assert_eq!(text.len(), 7);
let ranges = vec![0..7];
// Match "cd" = bytes 5..7, display cols 3..5.
let segments = byte_range_to_row_cols(text, &ranges, 5..7);
assert_eq!(
@@ -1340,7 +1343,7 @@ mod tests {
);
}
// -- Table line detection / no-wrap tests ----------------------------------
// Table line detection / no-wrap tests
#[test]
fn table_line_box_drawing_not_wrapped() {
@@ -1362,14 +1365,15 @@ mod tests {
assert_eq!(joiners, vec![None]);
}
/// Regression: a table row narrower than the content width must be padded
/// Regression: a table row narrower than the content width must be `padded`
/// so the app owns every column (otherwise a wide-glyph width disagreement
/// strands a ghost cell).
#[test]
fn table_row_padded_to_content_width() {
use unicode_width::UnicodeWidthStr;
let line = Line::from("│ Status │ Note │"); // 23 display columns
// 23 display columns
let line = Line::from("│ Status │ Note │");
assert_eq!(concat_line(&line).width(), 23);
let content_width = 40;
@@ -1390,7 +1394,7 @@ mod tests {
/// Faithful repro: a body row with an emoji-presentation sequence
/// (`⚠\u{FE0F}`) and an em-dash — the glyphs that desynced the cursor — must
/// be padded to exactly the content width.
/// be `padded` to exactly the content width.
#[test]
fn table_row_with_emoji_and_em_dash_fills_content_width() {
use unicode_width::UnicodeWidthStr;
@@ -1,8 +1,8 @@
//! Syntax highlighting initialization.
//!
//! Provides lazily-initialized `Syntect` instances for code highlighting.
//! Dark themes (KigiNight, TokyoNight) share `kigi-night.tmTheme`;
//! KigiDay uses `kigi-day.tmTheme` with deepened colors for light backgrounds.
//! One lazily-initialized `Syntect` per tmTheme, shared by every `ThemeKind`
//! that maps to it. KigiDay needs its own because `kigi-day.tmTheme` deepens
//! the palette for light backgrounds.
use std::sync::OnceLock;
@@ -14,7 +14,6 @@ static SYNTECT_KIGINIGHT: OnceLock<Syntect> = OnceLock::new();
static SYNTECT_TOKYONIGHT: OnceLock<Syntect> = OnceLock::new();
static SYNTECT_KIGIDAY: OnceLock<Syntect> = OnceLock::new();
/// Convert syntect style to ratatui foreground-only style, quantized for terminal color support.
pub fn syntect_to_ratatui_fg(style: syntect::highlighting::Style) -> ratatui::style::Style {
let fg = crate::theme::quantize(ratatui::style::Color::Rgb(
style.foreground.r,
@@ -35,7 +34,6 @@ pub fn syntect_to_ratatui_fg(style: syntect::highlighting::Style) -> ratatui::st
out
}
/// Highlight a single line of source, falling back to plain text style.
pub fn highlight_line(
text: &str,
highlighter: &mut Option<syntect::easy::HighlightLines<'_>>,
@@ -63,7 +61,6 @@ pub fn highlight_line(
vec![ratatui::text::Span::styled(text.to_string(), fallback)]
}
/// Returns the syntect instance matching the active theme.
pub fn get_syntect() -> &'static Syntect {
match crate::theme::Theme::current_kind() {
ThemeKind::KigiNight
@@ -31,7 +31,7 @@ pub enum EmbeddedEditor {
/// [`EmbeddedEditor::Emacs`]; else `None`. Empty values are treated as absent
/// (matching the sibling `detect_*_from_env` detectors via `env_get`).
///
/// Adding a new env marker here requires extending
/// Including a new env marker here requires extending
/// `HOST_TERMINAL_ENV_VARS` in `kigi-pager-pty-harness/src/pty.rs`
/// (test-env hygiene).
pub fn embedded_editor_from_env(env: &HashMap<String, String>) -> Option<EmbeddedEditor> {
@@ -56,7 +56,7 @@ pub struct HyperlinkCapabilities {
pub id_param: bool,
/// Which URL schemes the terminal handles.
pub scheme_filter: SchemeFilter,
/// Whether the terminal supports OSC 22 cursor-shape changes
/// Whether the terminal supports OSC 22 cursor-shape shifts
/// (e.g. switching to a hand/pointer cursor on link hover).
pub osc22_cursor: bool,
/// Whether the terminal handles link hover styling natively (so our
@@ -197,7 +197,7 @@ pub fn hyperlink_capabilities(brand: TerminalName) -> HyperlinkCapabilities {
}
}
// ── OSC 22 cursor-shape commands ──────────────────────────────────────
// OSC 22 cursor-shape commands
//
// These wrap raw OSC 22 sequences as crossterm `Command`s so call sites
// can use `crossterm::execute!` / `queue!` instead of manual byte writes.
@@ -3,7 +3,7 @@
//! Provides escape-sequence helpers for rendering images inside the
//! existing preview overlay. The text-fallback path in
//! [`crate::render::image_overlay`] remains the primary preview; this
//! module adds pixel-level rendering for supported terminals.
//! module provides pixel-level rendering for supported terminals.
//!
//! # Supported protocols
//!
@@ -27,9 +27,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use super::{TerminalName, terminal_context};
// -------------------------------------------------------------------------
// Graphics protocol detection
// -------------------------------------------------------------------------
/// Graphics protocol supported by the current terminal.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@@ -187,9 +185,7 @@ pub fn protocol_for_brand(brand: TerminalName, is_windows: bool) -> GraphicsProt
}
}
// -------------------------------------------------------------------------
// Kitty graphics protocol
// -------------------------------------------------------------------------
/// Shared placement ID; every renderer must coordinate through [`super::overlay`].
pub(super) const KITTY_PLACEMENT_ID: u32 = 1;
@@ -434,9 +430,7 @@ pub fn clear_kitty_image(image_id: u32) -> String {
format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", image_id)
}
// -------------------------------------------------------------------------
// iTerm2 inline images protocol
// -------------------------------------------------------------------------
/// Build an iTerm2 inline image escape sequence.
///
@@ -452,9 +446,7 @@ pub fn render_iterm2_image(image_data: &[u8], cols: u16, rows: u16) -> String {
)
}
// -------------------------------------------------------------------------
// Shared overlay helpers
// -------------------------------------------------------------------------
/// Build the full escape-sequence string to render image data at a cell
/// position using the provided graphics protocol.
@@ -506,7 +498,8 @@ pub(super) fn build_overlay_image_escapes_for_protocol(
KITTY_PLACEMENT_ID,
cols,
rows,
1, // above text (modal overlays)
// above text (modal overlays)
1,
));
}
GraphicsProtocol::ITerm2 => {
@@ -643,9 +636,7 @@ pub fn fit_image_to_cells(img_w: u32, img_h: u32, max_cols: u16, max_rows: u16)
}
}
// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
mod tests;
@@ -4,7 +4,7 @@
//! instead of branching on brand. The classification depends on the
//! current `HostOs`, queried internally — today only macOS rows are
//! populated. Extend [`KeyboardCapabilities`] with new fields (paste
//! protocol, focus reporting, custom escapes) instead of adding more
//! protocol, focus reporting, custom escapes) instead of stacking more
//! `match self.brand` sites scattered through the pager.
use super::TerminalName;
@@ -84,7 +84,7 @@ impl KeyboardCapabilities {
/// Classify keyboard capabilities for a given `(brand, os, display_server)`.
///
/// Today the table is populated only for macOS; other OSes return the
/// default (all-`Unknown`). When a Linux/Windows probe lands, add a
/// default (all-`Unknown`). When a Linux/Windows probe lands, include a
/// per-OS arm here rather than forking the function.
pub fn keyboard_capabilities(brand: TerminalName) -> KeyboardCapabilities {
match HostOs::current() {
@@ -152,7 +152,8 @@ pub enum TerminalName {
impl TerminalName {
pub fn is_vte_based(self) -> bool {
matches!(self, Self::Vte | Self::Terminator) // WHY: single source of truth for the VTE family
// WHY: single source of truth for the VTE family
matches!(self, Self::Vte | Self::Terminator)
}
/// VS Code integrated terminal and xterm.js-based IDE embeds (including forks).
@@ -197,7 +198,8 @@ impl TerminalName {
impl TerminalContext {
pub fn is_vte_based(&self) -> bool {
self.brand.is_vte_based() || self.vte_version.is_some() // WHY: covers brand + legacy version marker
// WHY: covers brand + legacy version marker
self.brand.is_vte_based() || self.vte_version.is_some()
}
}
@@ -210,7 +212,6 @@ pub enum MultiplexerKind {
/// GNU screen (including Byobu-on-screen).
#[strum(to_string = "GNU screen")]
Screen,
/// Zellij.
Zellij,
/// cmux (Ghostty-backed macOS terminal multiplexer).
#[strum(to_string = "cmux")]
@@ -456,7 +457,8 @@ impl TerminalContext {
/// In every case `Alt+Enter` (delivered as `ESC`+`CR`) is the reliable
/// newline chord and is what the UI advertises.
pub fn shift_enter_unavailable(&self) -> bool {
let is_vte = self.is_vte_based(); // WHY: central helper + version gating
// WHY: central helper + version gating
let is_vte = self.is_vte_based();
if is_vte {
return match self
.vte_version
@@ -671,7 +673,7 @@ fn env_get<'a>(env: &'a HashMap<String, String>, key: &str) -> Option<&'a str> {
///
/// This is the pure equivalent of the original `detect_terminal_info`.
///
/// Adding a new env marker to this brand chain (or to
/// Including a new env marker to this brand chain (or to
/// [`detect_byobu_from_env`] / [`detect_multiplexer_from_env`] below)
/// requires extending `HOST_TERMINAL_ENV_VARS` in
/// `kigi-pager-pty-harness/src/pty.rs` (test-env hygiene — the PTY
@@ -884,7 +886,8 @@ pub fn detect_multiplexer_from_env(env: &HashMap<String, String>) -> Multiplexer
match backend {
ByobuBackend::Tmux => return MultiplexerKind::Tmux,
ByobuBackend::Screen => return MultiplexerKind::Screen,
ByobuBackend::Unknown => {} // fall through to standard markers
// fall through to standard markers
ByobuBackend::Unknown => {}
}
}
@@ -959,7 +962,6 @@ pub fn build_terminal_context_from_env(env: &HashMap<String, String>) -> Termina
}
}
/// Map TERM_PROGRAM value to terminal name.
fn terminal_name_from_term_program(value: &str) -> Option<TerminalName> {
let normalized: String = value
.trim()
@@ -11,6 +11,9 @@ use super::image::{
static NEXT_OWNER_ID: AtomicU64 = AtomicU64::new(1);
thread_local! {
/// Owner whose image the terminal is believed to still hold; a matching
/// owner lets the next frame re-place that image without retransmitting
/// its pixel data.
static OWNER: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
}
@@ -20,6 +23,10 @@ pub(crate) enum Ownership {
Clear,
}
/// Escape bytes plus the ownership transition they imply. The transition is
/// applied only by [`Escapes::commit`] or [`PostFlush::write_to`], so escapes
/// that are built and then dropped — or that fail to reach the terminal —
/// leave `OWNER` describing what the terminal actually holds.
#[derive(Debug)]
pub struct Escapes {
bytes: String,
@@ -1,6 +1,6 @@
use super::*;
// -- terminal_name_from_term_program (existing coverage) ------------------
// terminal_name_from_term_program (existing coverage)
#[test]
fn test_terminal_name_from_term_program() {
@@ -89,7 +89,7 @@ fn otty_skips_kitty_keyboard_like_unknown() {
assert!(ctx.shift_enter_unavailable());
}
// -- detect_terminal_brand_from_env (pure) --------------------------------
// detect_terminal_brand_from_env (pure)
#[test]
fn brand_ghostty_from_term_program() {
@@ -187,10 +187,11 @@ fn brand_vte_from_vte_version() {
#[test]
fn brand_terminator_from_term_program() {
let env = env_from(&[("TERM_PROGRAM", "terminator")]);
// WHY: canonical per detect-terminal
assert_eq!(
detect_terminal_brand_from_env(&env),
TerminalName::Terminator
); // WHY: canonical per detect-terminal
);
}
#[test]
@@ -198,7 +199,8 @@ fn terminator_vte_version_interaction() {
let env = env_from(&[("TERM_PROGRAM", "terminator"), ("VTE_VERSION", "8200")]);
let ctx = build_terminal_context_from_env(&env);
assert_eq!(ctx.brand, TerminalName::Terminator);
assert!(ctx.is_vte_based()); // WHY: helper covers version + brand
// WHY: helper covers version + brand
assert!(ctx.is_vte_based());
}
#[test]
@@ -221,10 +223,11 @@ fn terminator_over_ssh() {
fn terminator_focus_tracking() {
let env = env_from(&[("TERM_PROGRAM", "terminator")]);
let ctx = build_terminal_context_from_env(&env);
// supports focus like VTE
assert!(!matches!(
ctx.brand,
TerminalName::AppleTerminal | TerminalName::Unknown
)); // supports focus like VTE
));
assert!(ctx.is_vte_based());
}
@@ -244,16 +247,18 @@ fn brand_unknown_empty_env() {
assert_eq!(detect_terminal_brand_from_env(&env), TerminalName::Unknown);
}
// -- refine_unknown_brand_for_host ---------------------------------------
// refine_unknown_brand_for_host
#[test]
fn refine_unknown_brand_defaults_to_wt_only_on_windows() {
use super::TerminalName::{Unknown, VsCode, WindowsTerminal};
use crate::host::HostOs::{Linux, Windows};
let cases = [
(Unknown, Windows, WindowsTerminal), // DefTerm handoff: no WT_SESSION
// DefTerm handoff: no WT_SESSION
(Unknown, Windows, WindowsTerminal),
(Unknown, Linux, Unknown),
(VsCode, Windows, VsCode), // never override a positively detected brand
// never override a positively detected brand
(VsCode, Windows, VsCode),
];
for (brand, host, expected) in cases {
assert_eq!(refine_unknown_brand_for_host(brand, host), expected);
@@ -301,7 +306,7 @@ fn mouse_reporting_leaks_only_for_jetbrains_on_windows() {
assert!(!mouse_reporting_leaks(TerminalName::Kitty, HostOs::Windows));
}
// -- detect_byobu_from_env ------------------------------------------------
// detect_byobu_from_env
#[test]
fn byobu_tmux_explicit_backend() {
@@ -342,7 +347,7 @@ fn no_byobu_markers_returns_none() {
assert_eq!(detect_byobu_from_env(&env), None);
}
// -- detect_multiplexer_from_env ------------------------------------------
// detect_multiplexer_from_env
#[test]
fn mux_plain_tmux() {
@@ -416,7 +421,7 @@ fn mux_tmux_nested_inside_cmux_wins() {
assert_eq!(detect_multiplexer_from_env(&env), MultiplexerKind::Tmux);
}
// -- ambiguous marker precedence ------------------------------------------
// ambiguous marker precedence
#[test]
fn tmux_beats_zellij_when_both_set() {
@@ -447,7 +452,7 @@ fn byobu_tmux_explicit_with_sty_stays_tmux() {
assert_eq!(detect_multiplexer_from_env(&env), MultiplexerKind::Tmux);
}
// -- detect_tmux_meta_from_env --------------------------------------------
// detect_tmux_meta_from_env
#[test]
fn tmux_meta_populated() {
@@ -470,7 +475,7 @@ fn tmux_meta_empty_outside_tmux() {
assert_eq!(meta, TmuxClientMeta::default());
}
// -- build_terminal_context_from_env (integration) ------------------------
// build_terminal_context_from_env (integration)
#[test]
fn context_plain_terminal() {
@@ -598,9 +603,7 @@ fn context_empty_env_values_ignored() {
assert_eq!(ctx.multiplexer, MultiplexerKind::Undetected);
}
// =====================================================================
// determine_alt_screen_policy: fullscreen policy matrix
// =====================================================================
fn plain_ctx() -> TerminalContext {
TerminalContext {
@@ -660,7 +663,7 @@ fn byobu_screen_ctx() -> TerminalContext {
}
}
// -- Alt-screen policy matrix (all modes × contexts × CLI override) -------
// Alt-screen policy matrix (all modes × contexts × CLI override)
#[derive(Debug)]
struct AltScreenCase {
@@ -900,7 +903,7 @@ fn alt_screen_policy_matrix() {
}
}
// -- Windows Terminal context integration ---------------------------------
// Windows Terminal context integration
#[test]
fn context_windows_terminal() {
@@ -911,13 +914,11 @@ fn context_windows_terminal() {
assert!(!ctx.is_ssh);
}
// -- Terminal brand detection edge cases -----------------------------------
// Terminal brand detection edge cases
// =====================================================================
// Extended environment matrix (final hardening)
// =====================================================================
// -- Byobu-screen: auto keeps fullscreen (screen is not auto-disabled) ----
// Byobu-screen: auto keeps fullscreen (screen is not auto-disabled)
#[test]
fn auto_byobu_screen_is_fullscreen() {
@@ -935,7 +936,7 @@ fn auto_byobu_screen_is_fullscreen() {
));
}
// -- Terminal brand detection edge cases -----------------------------------
// Terminal brand detection edge cases
#[test]
fn brand_vscode_from_term_program() {
@@ -978,7 +979,7 @@ fn brand_term_program_takes_precedence_over_other_vars() {
assert_eq!(detect_terminal_brand_from_env(&env), TerminalName::Ghostty);
}
// -- IDE family detection (VS Code forks / xterm.js embeds) ---------------
// IDE family detection (VS Code forks / xterm.js embeds)
#[test]
fn brand_cursor_from_cursor_trace_id() {
@@ -1054,7 +1055,7 @@ fn brand_vscode_from_askpass_without_term_program() {
assert_eq!(detect_terminal_brand_from_env(&env), TerminalName::VsCode);
}
// -- Zellij detection from ZELLIJ_VERSION (no ZELLIJ or SESSION_NAME) -----
// Zellij detection from ZELLIJ_VERSION (no ZELLIJ or SESSION_NAME)
#[test]
fn mux_zellij_not_from_version_only() {
@@ -1067,7 +1068,7 @@ fn mux_zellij_not_from_version_only() {
);
}
// -- Byobu inference edge cases -------------------------------------------
// Byobu inference edge cases
#[test]
fn byobu_unknown_backend_string_with_tmux() {
@@ -1088,7 +1089,7 @@ fn byobu_unknown_backend_no_mux_returns_none() {
assert_eq!(detect_byobu_from_env(&env), None);
}
// -- Context-level edge cases ---------------------------------------------
// Context-level edge cases
#[test]
fn context_sty_takes_screen_when_no_tmux_or_zellij() {
@@ -1137,9 +1138,7 @@ fn context_is_byobu_returns_false_without_byobu_markers() {
assert!(!build_terminal_context_from_env(&env).is_byobu());
}
// =====================================================================
// parse_tmux_major_minor: version string parsing
// =====================================================================
#[test]
fn parse_tmux_version_standard() {
@@ -1186,9 +1185,7 @@ fn parse_tmux_version_no_minor() {
assert_eq!(parse_tmux_major_minor("tmux 3"), None);
}
// =====================================================================
// parse_semver_major_minor: TERM_PROGRAM_VERSION parsing
// =====================================================================
#[test]
fn parse_semver_standard() {
@@ -1220,7 +1217,6 @@ fn parse_semver_major_only() {
assert_eq!(parse_semver_major_minor("3"), None);
}
// =====================================================================
// graphics_protocol_skip_reason
#[test]
@@ -1243,7 +1239,6 @@ fn graphics_protocol_skip_reason_plain_kitty() {
}
// kitty_skip_reason: Kitty keyboard protocol skip-reason matrix
// =====================================================================
#[test]
fn kitty_skip_vscode() {
@@ -1466,9 +1461,7 @@ fn kitty_skip_vte_brand() {
assert_eq!(ctx.kitty_skip_reason(), Some("vte"));
}
// =====================================================================
// shift_enter_unavailable: VTE version gating for Shift+Enter
// =====================================================================
//
// VTE 0.82.0 (= VTE_VERSION 8200) is the first release containing the
// Kitty keyboard protocol; earlier versions cannot distinguish
@@ -1550,7 +1543,8 @@ fn shift_enter_available_kkp_terminals() {
] {
let ctx = TerminalContext {
brand,
env_brand: brand, // lockstep with brand (no Windows refinement)
// lockstep with brand (no Windows refinement)
env_brand: brand,
..Default::default()
};
assert!(
@@ -1738,7 +1732,7 @@ fn ctrl_dot_unreliable_on_unknown_no_multiplexer() {
assert!(ctx.ctrl_dot_unreliable());
}
// -- tmux extended-keys interaction with kitty_skip_reason ---------------
// tmux extended-keys interaction with kitty_skip_reason
fn extended_keys_ctx(version: &str, extended_keys: Option<&str>) -> TerminalContext {
TerminalContext {
@@ -1803,9 +1797,7 @@ fn kitty_skip_vte_takes_precedence_over_tmux_old() {
assert_eq!(ctx.kitty_skip_reason(), Some("vte"));
}
// =====================================================================
// JetBrains JediTerm detection
// =====================================================================
#[test]
fn brand_jetbrains_from_terminal_emulator() {
@@ -21,7 +21,6 @@
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
/// Startup probe outcome.
#[derive(Debug)]
enum ProbeResult {
Skipped,
@@ -32,7 +31,6 @@ enum ProbeResult {
/// Unset while the query is in flight (or never sent).
static XTVERSION: OnceLock<ProbeResult> = OnceLock::new();
/// True once the query bytes were written to the terminal.
static QUERY_SENT: AtomicBool = AtomicBool::new(false);
/// XTVERSION query alone — no DA1 sentinel: nothing waits on reply
@@ -122,7 +120,6 @@ fn send_query() {
let _ = XTVERSION.set(ProbeResult::Skipped);
}
/// Strip controls and trim; `None` for an empty payload.
fn sanitize_payload(payload: &str) -> Option<String> {
let cleaned: String = payload.chars().filter(|c| !c.is_control()).collect();
let cleaned = cleaned.trim().to_owned();
@@ -183,12 +180,10 @@ mod tests {
gate_allows_probe(&ctx(brand, MultiplexerKind::Undetected)),
"{brand:?} should be probed"
);
// Transparent mux (cmux) does not intercept CSI; probe still runs.
assert!(
gate_allows_probe(&ctx(brand, MultiplexerKind::Cmux)),
"{brand:?} under cmux should still be probed"
);
// CSI-intercepting multiplexers override the brand allowlist.
assert!(
!gate_allows_probe(&ctx(brand, MultiplexerKind::Tmux)),
"{brand:?} under tmux should be skipped"
@@ -102,7 +102,7 @@ pub fn set(kind: ThemeKind) {
LOADED.store(true, Ordering::Release);
}
// -- Terminal-native lock (minimal mode) --------------------------------------
// Terminal-native lock (minimal mode)
/// Whether the theme is locked to the terminal-native palette.
#[must_use]
@@ -120,7 +120,7 @@ pub fn set_terminal_native_lock(locked: bool) {
});
}
// -- Auto-mode ---------------------------------------------------------------
// Auto-mode
/// Whether auto-switching mode is active.
#[must_use]
@@ -152,7 +152,7 @@ pub fn invalidate_auto_theme_config() {
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
// -- Theme resolution --------------------------------------------------------
// Theme resolution
/// Resolve the effective theme, respecting the full precedence chain.
///
@@ -218,7 +218,7 @@ pub fn resolve_initial_theme_no_osc11() -> ThemeKind {
resolve_from_config(load_from_disk(), false)
}
// -- Disk reads --------------------------------------------------------------
// Disk reads
//
// All writes go through `kigi_shell::util::config::set_theme()` (and
// friends) via `Effect::PersistSetting`. This module only READS from the
@@ -268,7 +268,7 @@ fn load_auto_theme_config() -> AutoThemeConfig {
}
}
// -- Test support ------------------------------------------------------------
// Test support
#[cfg(any(test, feature = "test-support"))]
pub fn reset_for_test() {
@@ -334,7 +334,7 @@ mod tests {
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = Some(config);
}
// -- Terminal-native lock (minimal mode) ----------------------------------
// Terminal-native lock (minimal mode)
#[test]
fn terminal_native_lock_pins_kind_and_blocks_apply_kind() {
@@ -393,7 +393,8 @@ mod tests {
set_terminal_native_lock(true);
assert!(color_support::detect() <= color_support::ColorLevel::Basic);
for input in [
Color::Rgb(0x26, 0x26, 0x26), // kigiday text_primary
// kigiday text_primary
Color::Rgb(0x26, 0x26, 0x26),
Color::Rgb(122, 162, 247),
Color::Indexed(141),
] {
@@ -427,7 +428,7 @@ mod tests {
});
}
// -- AUTO_MODE -----------------------------------------------------------
// AUTO_MODE
#[test]
fn auto_mode_default_is_false() {
@@ -446,7 +447,7 @@ mod tests {
});
}
// -- AutoThemeConfig -----------------------------------------------------
// AutoThemeConfig
#[test]
fn auto_theme_config_defaults_to_none() {
@@ -455,7 +456,7 @@ mod tests {
assert!(config.light_theme.is_none());
}
// -- resolve_auto --------------------------------------------------------
// resolve_auto
#[test]
fn resolve_auto_dark_system_returns_kiginight() {
@@ -484,7 +485,7 @@ mod tests {
});
}
// -- invalidate_auto_theme_config ----------------------------------------
// invalidate_auto_theme_config
#[test]
fn invalidate_clears_cached_config() {
@@ -506,7 +507,7 @@ mod tests {
});
}
// -- resolve_from_config (resolve_initial_theme inner logic) ---------------
// resolve_from_config (resolve_initial_theme inner logic)
#[test]
fn resolve_from_config_no_config_returns_kiginight() {
@@ -559,7 +560,7 @@ mod tests {
});
}
// -- resolve_auto with custom config -------------------------------------
// resolve_auto with custom config
#[test]
fn resolve_auto_with_custom_dark_config() {
@@ -585,7 +586,7 @@ mod tests {
});
}
// -- auto_theme_config filter --------------------------------------------
// auto_theme_config filter
#[test]
fn auto_theme_config_filter_rejects_auto_value() {
@@ -602,7 +603,7 @@ mod tests {
assert_eq!(parsed, Some(ThemeKind::TokyoNight));
}
// -- set / current_kind --------------------------------------------------
// set / current_kind
/// `set` followed by `current_kind` returns the set value, and the
/// `LOADED` flag flips so subsequent reads don't re-seed from disk.
@@ -60,7 +60,7 @@ impl std::fmt::Display for ColorLevel {
}
}
// ── Global singleton ─────────────────────────────────────────────────────
// Global singleton
static COLOR_LEVEL: OnceLock<ColorLevel> = OnceLock::new();
@@ -133,7 +133,7 @@ pub fn set(level: ColorLevel) -> Result<(), ColorLevel> {
COLOR_LEVEL.set(level)
}
// ── Color quantization ──────────────────────────────────────────────────
// Color quantization
/// Downgrade a [`Color`] to the highest representation the terminal supports.
///
@@ -164,7 +164,7 @@ pub fn quantize(color: Color) -> Color {
quantize_color(color, get())
}
// ── Terminal-based truecolor inference ──────────────────────────────────
// Terminal-based truecolor inference
/// Check whether the detected terminal emulator is known to support truecolor.
///
@@ -196,7 +196,7 @@ fn terminal_supports_truecolor() -> bool {
cfg!(target_os = "windows")
}
// ── 256 → 16 mapping ────────────────────────────────────────────────────
// 256 → 16 mapping
/// Map a 256-color index to the nearest basic ANSI 16 color.
fn indexed_to_ansi16(n: u8) -> Color {
@@ -209,7 +209,8 @@ fn indexed_to_ansi16(n: u8) -> Color {
4 => Color::Blue,
5 => Color::Magenta,
6 => Color::Cyan,
7 => Color::White, // actually "silver" in most terminals
// actually "silver" in most terminals
7 => Color::White,
8 => Color::DarkGray,
9 => Color::LightRed,
10 => Color::LightGreen,
@@ -248,7 +249,8 @@ fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
(0, 0, 255, Color::LightBlue),
(255, 0, 255, Color::LightMagenta),
(0, 255, 255, Color::LightCyan),
(255, 255, 255, Color::White), // index 15 = bright white
// index 15 = bright white
(255, 255, 255, Color::White),
];
let mut best = Color::White;
@@ -16,38 +16,48 @@ const fn rgb(r: u8, g: u8, b: u8) -> Color {
mod palette {
use super::*;
// ── Backgrounds (neutral light grays) ────────────────────────────────
pub const BG: Color = rgb(245, 245, 245); // #f5f5f5 — brightest (terminal bg)
pub const BG_DARK: Color = rgb(240, 240, 240); // #f0f0f0
pub const BG_STORM_DARK: Color = rgb(234, 234, 234); // #eaeaea
pub const BG_STORM: Color = rgb(238, 238, 238); // #eeeeee — main bg
pub const BG_HIGHLIGHT: Color = rgb(222, 222, 222); // #dedede — highlight bg
// Backgrounds (neutral light grays)
// #f5f5f5 — brightest (terminal bg)
pub const BG: Color = rgb(245, 245, 245);
pub const BG_DARK: Color = rgb(240, 240, 240);
pub const BG_STORM_DARK: Color = rgb(234, 234, 234);
// #eeeeee — main bg
pub const BG_STORM: Color = rgb(238, 238, 238);
// #dedede — highlight bg
pub const BG_HIGHLIGHT: Color = rgb(222, 222, 222);
// ── Text / grays (neutral dark) ──────────────────────────────────────
pub const FG: Color = rgb(38, 38, 38); // #262626 — primary text
pub const FG_DARK: Color = rgb(68, 68, 68); // #444444 — secondary text
pub const FG_GUTTER: Color = rgb(178, 178, 178); // #b2b2b2 — dim
pub const COMMENT: Color = rgb(118, 118, 118); // #767676 — muted
pub const DARK3: Color = rgb(142, 142, 142); // #8e8e8e — medium gray
pub const DARK5: Color = rgb(98, 98, 98); // #626262 — bright gray
// Text / grays (neutral dark)
// #262626 — primary text
pub const FG: Color = rgb(38, 38, 38);
// #444444 — secondary text
pub const FG_DARK: Color = rgb(68, 68, 68);
pub const FG_GUTTER: Color = rgb(178, 178, 178);
// #767676 — muted
pub const COMMENT: Color = rgb(118, 118, 118);
// #8e8e8e — medium gray
pub const DARK3: Color = rgb(142, 142, 142);
// #626262 — bright gray
pub const DARK5: Color = rgb(98, 98, 98);
// ── Accent colors (deepened for light-background contrast) ───────────
pub const BLUE: Color = rgb(47, 100, 210); // #2F64D2
pub const BLUE0: Color = rgb(40, 68, 138); // #28448A
pub const BLUE1: Color = rgb(15, 135, 162); // #0F87A2
pub const CYAN: Color = rgb(0, 130, 170); // #0082AA
pub const GREEN: Color = rgb(55, 142, 35); // #378E23
pub const GREEN1: Color = rgb(12, 148, 124); // #0C947C
pub const MAGENTA: Color = rgb(125, 75, 198); // #7D4BC6
pub const ORANGE: Color = rgb(195, 105, 30); // #C3691E
pub const PURPLE: Color = rgb(108, 62, 178); // #6C3EB2
pub const RED: Color = rgb(205, 48, 72); // #CD3048
pub const RED1: Color = rgb(175, 35, 35); // #AF2323
pub const TEAL: Color = rgb(10, 142, 112); // #0A8E70
pub const YELLOW: Color = rgb(162, 118, 18); // #A27612
// Accent colors (deepened for light-background contrast)
pub const BLUE: Color = rgb(47, 100, 210);
pub const BLUE0: Color = rgb(40, 68, 138);
pub const BLUE1: Color = rgb(15, 135, 162);
pub const CYAN: Color = rgb(0, 130, 170);
pub const GREEN: Color = rgb(55, 142, 35);
pub const GREEN1: Color = rgb(12, 148, 124);
pub const MAGENTA: Color = rgb(125, 75, 198);
pub const ORANGE: Color = rgb(195, 105, 30);
pub const PURPLE: Color = rgb(108, 62, 178);
pub const RED: Color = rgb(205, 48, 72);
pub const RED1: Color = rgb(175, 35, 35);
pub const TEAL: Color = rgb(10, 142, 112);
pub const YELLOW: Color = rgb(162, 118, 18);
pub const RED_LIGHT: Color = rgb(245, 218, 222); // #F5DADE — diff delete bg
pub const GREEN_LIGHT: Color = rgb(218, 242, 220); // #DAF2DC — diff insert bg
// #F5DADE — diff delete bg
pub const RED_LIGHT: Color = rgb(245, 218, 222);
// #DAF2DC — diff insert bg
pub const GREEN_LIGHT: Color = rgb(218, 242, 220);
}
use palette::*;
@@ -74,7 +84,8 @@ impl Theme {
text_primary: FG,
text_secondary: FG_DARK,
gray_dim: rgb(165, 165, 165), // #a5a5a5 — slightly darker than FG_GUTTER
// #a5a5a5 — slightly darker than FG_GUTTER
gray_dim: rgb(165, 165, 165),
gray: COMMENT,
gray_bright: DARK5,
@@ -85,17 +96,22 @@ impl Theme {
fuzzy_accent: BLUE,
accent_plan: rgb(168, 120, 10), // #A8780A — deep golden
// #A8780A — deep golden
accent_plan: rgb(168, 120, 10),
accent_verify: rgb(120, 80, 160), // deep violet (readable on light bg)
// deep violet (readable on light bg)
accent_verify: rgb(120, 80, 160),
accent_feedback: GREEN1,
accent_remember: rgb(76, 175, 80), // #4CAF50 — Material Design green (readable on light bg)
// #4CAF50 — Material Design green (readable on light bg)
accent_remember: rgb(76, 175, 80),
selection_border: rgb(185, 185, 190),
prompt_border: rgb(200, 200, 205), // #C8C8CD — dimmer prompt chrome
prompt_border_active: rgb(165, 165, 175), // #A5A5AF — darker (more apparent) when focused
// #C8C8CD — dimmer prompt chrome
prompt_border: rgb(200, 200, 205),
// #A5A5AF — darker (more apparent) when focused
prompt_border_active: rgb(165, 165, 175),
hover_border: rgb(212, 212, 216),
accent_model: TEAL,
@@ -134,7 +150,8 @@ impl Theme {
md_muted: COMMENT,
md_code_bg: rgb(228, 228, 228),
md_text: FG_DARK,
link_fg: BLUE, // #2F64D2 -- deep blue for light bg
// #2F64D2 -- deep blue for light bg
link_fg: BLUE,
}
}
}
@@ -24,38 +24,50 @@ const fn rgb(r: u8, g: u8, b: u8) -> Color {
mod palette {
use super::*;
// ── Backgrounds ─────────────────────────────────────────────────────
pub const BG: Color = rgb(10, 10, 10); // #0a0a0a — Night (terminal bg)
pub const BG_DARK: Color = rgb(12, 12, 12); // #0c0c0c — darkest
pub const BG_STORM_DARK: Color = rgb(17, 17, 17); // #111111 — dark bg
pub const BG_STORM: Color = rgb(20, 20, 20); // #141414 — main bg
pub const BG_HIGHLIGHT: Color = rgb(36, 36, 36); // #242424 — highlight bg
// Backgrounds
// #0a0a0a — Night (terminal bg)
pub const BG: Color = rgb(10, 10, 10);
// #0c0c0c — darkest
pub const BG_DARK: Color = rgb(12, 12, 12);
// #111111 — dark bg
pub const BG_STORM_DARK: Color = rgb(17, 17, 17);
// #141414 — main bg
pub const BG_STORM: Color = rgb(20, 20, 20);
// #242424 — highlight bg
pub const BG_HIGHLIGHT: Color = rgb(36, 36, 36);
// ── Text / grays ────────────────────────────────────────────────────
pub const FG: Color = rgb(225, 225, 225); // #e1e1e1 — primary text
pub const FG_DARK: Color = rgb(200, 200, 200); // #c8c8c8 — secondary text
pub const FG_GUTTER: Color = rgb(65, 65, 65); // #414141 — dim
pub const COMMENT: Color = rgb(108, 108, 108); // #6c6c6c — muted
pub const DARK3: Color = rgb(90, 90, 90); // #5a5a5a — medium gray
pub const DARK5: Color = rgb(120, 120, 120); // #787878 — bright gray
// Text / grays
// #e1e1e1 — primary text
pub const FG: Color = rgb(225, 225, 225);
// #c8c8c8 — secondary text
pub const FG_DARK: Color = rgb(200, 200, 200);
pub const FG_GUTTER: Color = rgb(65, 65, 65);
// #6c6c6c — muted
pub const COMMENT: Color = rgb(108, 108, 108);
// #5a5a5a — medium gray
pub const DARK3: Color = rgb(90, 90, 90);
// #787878 — bright gray
pub const DARK5: Color = rgb(120, 120, 120);
// ── Accent colors (TokyoNight Night) ─────────────────────────────────
pub const BLUE: Color = rgb(122, 162, 247); // #7aa2f7
pub const BLUE0: Color = rgb(61, 89, 161); // #3d59a1
pub const BLUE1: Color = rgb(58, 149, 171); // #3A95AB
pub const CYAN: Color = rgb(125, 207, 255); // #7dcfff
pub const GREEN: Color = rgb(158, 206, 106); // #9ece6a
pub const GREEN1: Color = rgb(115, 218, 202); // #73daca
pub const MAGENTA: Color = rgb(187, 154, 247); // #bb9af7
pub const ORANGE: Color = rgb(255, 158, 100); // #ff9e64
pub const PURPLE: Color = rgb(157, 124, 216); // #9d7cd8
pub const RED: Color = rgb(247, 118, 142); // #f7768e
pub const RED1: Color = rgb(219, 75, 75); // #db4b4b
pub const TEAL: Color = rgb(26, 188, 156); // #1abc9c
pub const YELLOW: Color = rgb(224, 175, 104); // #e0af68
// Accent colors (TokyoNight Night)
pub const BLUE: Color = rgb(122, 162, 247);
pub const BLUE0: Color = rgb(61, 89, 161);
pub const BLUE1: Color = rgb(58, 149, 171);
pub const CYAN: Color = rgb(125, 207, 255);
pub const GREEN: Color = rgb(158, 206, 106);
pub const GREEN1: Color = rgb(115, 218, 202);
pub const MAGENTA: Color = rgb(187, 154, 247);
pub const ORANGE: Color = rgb(255, 158, 100);
pub const PURPLE: Color = rgb(157, 124, 216);
pub const RED: Color = rgb(247, 118, 142);
pub const RED1: Color = rgb(219, 75, 75);
pub const TEAL: Color = rgb(26, 188, 156);
pub const YELLOW: Color = rgb(224, 175, 104);
pub const RED_DARK: Color = rgb(66, 14, 20); // #420e14 — quantizes to 256-color red, not gray
pub const GREEN_DARK: Color = rgb(6, 56, 6); // #063806 — quantizes to 256-color green, not gray
// #420e14 — quantizes to 256-color red, not gray
pub const RED_DARK: Color = rgb(66, 14, 20);
// #063806 — quantizes to 256-color green, not gray
pub const GREEN_DARK: Color = rgb(6, 56, 6);
}
use palette::*;
@@ -68,7 +80,8 @@ impl Theme {
Self {
bg_base: BG_STORM,
bg_light: BG_HIGHLIGHT,
bg_dark: rgb(28, 28, 28), // lighter than bg_base for visible code blocks
// lighter than bg_base for visible code blocks
bg_dark: rgb(28, 28, 28),
bg_highlight: BG_HIGHLIGHT,
bg_hover: rgb(44, 44, 44),
bg_terminal: BG,
@@ -86,7 +99,8 @@ impl Theme {
text_primary: FG,
text_secondary: FG_DARK,
gray_dim: rgb(88, 88, 88), // #585858 — slightly brighter than FG_GUTTER
// #585858 — slightly brighter than FG_GUTTER
gray_dim: rgb(88, 88, 88),
gray: COMMENT,
gray_bright: DARK5,
@@ -97,17 +111,22 @@ impl Theme {
fuzzy_accent: BLUE,
accent_plan: rgb(255, 219, 141), // #FFDB8D — golden
// #FFDB8D — golden
accent_plan: rgb(255, 219, 141),
accent_verify: rgb(187, 154, 247), // #bb9af7 — violet
// #bb9af7 — violet
accent_verify: rgb(187, 154, 247),
accent_feedback: GREEN1, // #73daca
accent_feedback: GREEN1,
accent_remember: Color::Rgb(139, 195, 74), // #8BC34A — Material Design light green
// #8BC34A — Material Design light green
accent_remember: Color::Rgb(139, 195, 74),
selection_border: rgb(60, 60, 65),
prompt_border: rgb(50, 50, 55), // #323237 — dimmer prompt chrome
prompt_border_active: rgb(80, 80, 88), // #505058 — brighter when focused
// #323237 — dimmer prompt chrome
prompt_border: rgb(50, 50, 55),
// #505058 — brighter when focused
prompt_border_active: rgb(80, 80, 88),
hover_border: rgb(30, 30, 34),
accent_model: TEAL,
@@ -134,19 +153,21 @@ impl Theme {
md_heading_h2_mod: Modifier::BOLD,
md_heading_h3: PURPLE,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: DARK5, // bright gray
md_heading_h4: DARK5,
md_heading_h4_mod: Modifier::BOLD,
md_heading_h5: COMMENT, // medium gray
md_heading_h5: COMMENT,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: DARK3, // medium gray, unbold
// medium gray, unbold
md_heading_h6: DARK3,
md_heading_h6_mod: Modifier::empty(),
md_code: BLUE1,
md_task_checked: GREEN,
md_task_unchecked: FG_DARK, // text_secondary
md_task_unchecked: FG_DARK,
md_muted: COMMENT,
md_code_bg: rgb(28, 28, 28),
md_text: FG_DARK,
link_fg: rgb(122, 166, 218), // #7aa6da -- soft blue for dark bg
// #7aa6da -- soft blue for dark bg
link_fg: rgb(122, 166, 218),
}
}
}
@@ -137,7 +137,7 @@ fn build_style() -> MarkdownStyle {
inline_code_outer: fg(theme.md_code).dimmed().hidden(),
// Selection-side bar detection (kigi-tui scrollback/blocks/
// quote_bar.rs quote_bar_style) mirrors this exact style; its
// end-to-end tests fail if this line changes.
// end-to-end tests fail if this line drifts.
blockquote_outer: fg(theme.md_muted).dimmed(),
task_checked: fg(theme.md_task_checked),
task_unchecked: fg(theme.md_task_unchecked).dimmed(),
@@ -476,7 +476,7 @@ impl Theme {
// doesn't read at the same weight as secondary text.
let dim_fg = if dark { Color::DarkGray } else { Color::Gray };
// ── Polarity-aware semantic hues ────────────────────────────
// Polarity-aware semantic hues
// Normal ANSI hues (idx 17) are designed at ~50% luminance and
// read well on light backgrounds. Light variants (idx 915) are
// full saturation and read well on dark backgrounds. Pinning by
@@ -502,7 +502,7 @@ impl Theme {
};
let cyan = if dark { Color::LightCyan } else { Color::Cyan };
Self {
// ── Elevated surfaces: one step off the canvas ──────────────
// Elevated surfaces: one step off the canvas
// Hover/highlight/visual-selection rows need to read as a
// distinct "raised" band against the body. Without this every
// KigiNight bg field quantizes to Color::Black and these
@@ -512,7 +512,7 @@ impl Theme {
bg_hover: elevated_bg,
bg_visual: elevated_bg,
// ── Canvas-matching surfaces ────────────────────────────────
// Canvas-matching surfaces
// Pin to the theme's polarity, NOT Color::Reset. The truecolor
// "subtle sunken / code block" effect can't be replicated in
// 16-color, but using the theme polarity guarantees these
@@ -523,7 +523,7 @@ impl Theme {
paste_bg: canvas_bg,
scrollbar_bg: canvas_bg,
// ── Borders: dim (idle) → muted (selection) → high-contrast (active) ──
// Borders: dim (idle) → muted (selection) → high-contrast (active)
// The four-tier truecolor border hierarchy collapses onto
// three ANSI16 slots:
// - `prompt_border` (idle text-input frame) → `dim_fg`,
@@ -544,7 +544,7 @@ impl Theme {
// Scrollbar thumb stays visible against the canvas-matched track.
scrollbar_fg: muted_fg,
// ── Foreground / text hierarchy ─────────────────────────────
// Foreground / text hierarchy
md_text: high_contrast_fg,
// Selected user-prompt `>` (drives the user selection accent
// and the OSC 12 cursor color) takes max-contrast fg so the
@@ -563,7 +563,7 @@ impl Theme {
gray_bright: muted_fg,
gray_dim: dim_fg,
// ── Semantic accents: polarity-aware hue pins ───────────────
// Semantic accents: polarity-aware hue pins
// State signals (running / completed / error) and content
// categories (system / skill / etc.) get
// pinned to a hue that survives ANSI16 instead of collapsing
@@ -217,7 +217,7 @@ fn ends_with_osc_terminator(buf: &[u8]) -> bool {
mod tests {
use super::*;
// -- ends_with_osc_terminator ---------------------------------------------
// ends_with_osc_terminator
#[test]
fn unterminated_reply_is_rejected() {
@@ -230,7 +230,7 @@ mod tests {
assert!(!ends_with_osc_terminator(b""));
}
// -- parse_osc11_rgb -----------------------------------------------------
// parse_osc11_rgb
#[test]
fn parse_4digit_white() {
@@ -310,7 +310,7 @@ mod tests {
assert_eq!(parse_osc11_rgb(response), Some((15, 15, 15)));
}
// -- parse_channel -------------------------------------------------------
// parse_channel
#[test]
fn channel_4digit_max() {
@@ -337,7 +337,7 @@ mod tests {
assert_eq!(parse_channel(" ff "), Some(255));
}
// -- classify_luminance --------------------------------------------------
// classify_luminance
#[test]
fn classify_pure_black_is_dark() {
@@ -387,7 +387,7 @@ mod tests {
);
}
// -- srgb_to_linear ------------------------------------------------------
// srgb_to_linear
#[test]
fn srgb_to_linear_zero() {
@@ -416,7 +416,7 @@ mod tests {
assert!((result - expected).abs() < 1e-10);
}
// -- detect_via_osc11 (graceful degradation) -----------------------------
// detect_via_osc11 (graceful degradation)
#[test]
fn detect_returns_none_when_not_tty() {
@@ -19,38 +19,53 @@ const fn rgb(r: u8, g: u8, b: u8) -> Color {
mod palette {
use super::*;
// -- backgrounds (OKLCH hue 265 backgrounds, OKLCH hue 265) -------
pub const BASE: Color = rgb(3, 3, 4); // #030304 oklch(0.1 0.005 265)
pub const SURFACE: Color = rgb(4, 5, 7); // #040507 oklch(0.115 0.005 265)
pub const ELEVATED: Color = rgb(15, 18, 22); // #0F1216 oklch(0.18 0.01 265)
pub const PANEL: Color = rgb(4, 4, 6); // #040406 oklch(0.11 0.006 265)
// backgrounds (OKLCH hue 265 backgrounds, OKLCH hue 265)
// #030304 oklch(0.1 0.005 265)
pub const BASE: Color = rgb(3, 3, 4);
// #040507 oklch(0.115 0.005 265)
pub const SURFACE: Color = rgb(4, 5, 7);
// #0F1216 oklch(0.18 0.01 265)
pub const ELEVATED: Color = rgb(15, 18, 22);
// #040406 oklch(0.11 0.006 265)
pub const PANEL: Color = rgb(4, 4, 6);
// -- text (neutral, no color cast) ----------------------------------------
pub const TEXT: Color = rgb(228, 228, 228); // #E4E4E4 oklch(0.92 0 0)
pub const TEXT_DIM: Color = rgb(190, 190, 190); // #BEBEBE oklch(0.8 0 0)
// text (neutral, no color cast)
// #E4E4E4 oklch(0.92 0 0)
pub const TEXT: Color = rgb(228, 228, 228);
// #BEBEBE oklch(0.8 0 0)
pub const TEXT_DIM: Color = rgb(190, 190, 190);
// -- muted text (slight blue-purple tint) ---------------------------------
pub const MUTED: Color = rgb(129, 134, 143); // #81868F oklch(0.62 0.015 260)
pub const SUBTLE: Color = rgb(94, 100, 108); // #5E646C oklch(0.5 0.015 260)
// muted text (slight blue-purple tint)
// #81868F oklch(0.62 0.015 260)
pub const MUTED: Color = rgb(129, 134, 143);
// #5E646C oklch(0.5 0.015 260)
pub const SUBTLE: Color = rgb(94, 100, 108);
// -- semantic colors (from desktop action tokens) -------------------------
pub const GOLD: Color = rgb(235, 217, 110); // #EBD96E oklch(0.88 0.13 100)
pub const RED: Color = rgb(220, 90, 100); // #DC5A64 muted rose-red
pub const TEAL: Color = rgb(80, 180, 140); // #50B48C softened teal
pub const AMBER: Color = rgb(241, 189, 0); // #F1BD00 oklch(0.82 0.18 90)
// semantic colors (from desktop action tokens)
// #EBD96E oklch(0.88 0.13 100)
pub const GOLD: Color = rgb(235, 217, 110);
// #DC5A64 muted rose-red
pub const RED: Color = rgb(220, 90, 100);
// #50B48C softened teal
pub const TEAL: Color = rgb(80, 180, 140);
// #F1BD00 oklch(0.82 0.18 90)
pub const AMBER: Color = rgb(241, 189, 0);
// -- purple accent ramp (the "purple hints") ------------------------------
pub const PURPLE: Color = rgb(155, 126, 206); // #9B7ECE — signature purple
pub const PURPLE_DIM: Color = rgb(110, 90, 154); // #6E5A9A — muted purple
pub const PURPLE_BRIGHT: Color = rgb(196, 167, 231); // #C4A7E7 — vivid lavender
// purple accent ramp (the "purple hints")
// #9B7ECE — signature purple
pub const PURPLE: Color = rgb(155, 126, 206);
// #6E5A9A — muted purple
pub const PURPLE_DIM: Color = rgb(110, 90, 154);
// #C4A7E7 — vivid lavender
pub const PURPLE_BRIGHT: Color = rgb(196, 167, 231);
// -- cyan (for running indicators, links) ---------------------------------
pub const CYAN: Color = rgb(125, 207, 223); // #7DCFDF
// cyan (for running indicators, links)
pub const CYAN: Color = rgb(125, 207, 223);
// -- highlight ramp (purple-tinted grays for UI chrome) -------------------
pub const HIGHLIGHT_LOW: Color = rgb(18, 16, 28); // #12101C
pub const HIGHLIGHT_MED: Color = rgb(36, 32, 52); // #242034
pub const HIGHLIGHT_HIGH: Color = rgb(52, 48, 72); // #343048
// highlight ramp (purple-tinted grays for UI chrome)
pub const HIGHLIGHT_LOW: Color = rgb(18, 16, 28);
pub const HIGHLIGHT_MED: Color = rgb(36, 32, 52);
pub const HIGHLIGHT_HIGH: Color = rgb(52, 48, 72);
}
use palette::*;
@@ -94,7 +109,8 @@ impl Theme {
accent_feedback: TEAL,
accent_remember: rgb(139, 195, 74), // #8BC34A — Material Design light green
// #8BC34A — Material Design light green
accent_remember: rgb(139, 195, 74),
selection_border: HIGHLIGHT_HIGH,
hover_border: HIGHLIGHT_MED,
@@ -111,7 +111,8 @@ impl Theme {
md_muted: MUTED,
md_code_bg: SURFACE,
md_text: TEXT,
link_fg: FOAM, // #9ccfd8 -- teal/cyan for dark bg
// #9ccfd8 -- teal/cyan for dark bg
link_fg: FOAM,
}
}
}
@@ -112,7 +112,7 @@ const POLL_INTERVAL: Duration = Duration::from_secs(5);
#[cfg(test)]
const POLL_INTERVAL: Duration = Duration::from_millis(50);
/// Watches for system appearance changes via polling.
/// Watches for system appearance shifts via polling.
///
/// The spawned polling task only reads system state and sends via
/// `watch::channel` — it never mutates `theme_cache::CURRENT` or `AUTO_MODE`.
@@ -155,7 +155,7 @@ impl SystemAppearanceWatcher {
})
}
/// Wait for the next appearance change.
/// Wait for the next appearance shift.
pub async fn changed(&mut self) -> Result<(), watch::error::RecvError> {
self.rx.changed().await
}
@@ -173,7 +173,7 @@ impl Drop for SystemAppearanceWatcher {
}
}
// -- Test support ----------------------------------------------------------
// Test support
#[cfg(any(test, feature = "test-support"))]
use std::sync::Mutex;
@@ -306,7 +306,7 @@ mod tests {
let _ = detect();
}
// -- SystemAppearanceWatcher -----------------------------------------
// SystemAppearanceWatcher
#[tokio::test]
async fn start_if_auto_returns_none_when_not_auto() {
@@ -347,7 +347,8 @@ mod tests {
}
#[tokio::test]
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
// Deliberate: theme_cache::test_lock() serializes mock access.
#[allow(clippy::await_holding_lock)]
async fn watcher_detects_appearance_change() {
let _guard = theme_cache::test_lock()
.lock()
@@ -356,10 +357,10 @@ mod tests {
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
// Change the mock appearance.
// Set the mock appearance.
set_mock(Some(SystemAppearance::Light));
// Wait for the watcher to detect the change (polls every 50ms in tests).
// Wait for the watcher to detect the shift (polls every 50ms in tests).
tokio::time::timeout(std::time::Duration::from_secs(2), watcher.changed())
.await
.expect("timed out waiting for change")
@@ -370,7 +371,8 @@ mod tests {
}
#[tokio::test]
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
// Deliberate: theme_cache::test_lock() serializes mock access.
#[allow(clippy::await_holding_lock)]
async fn watcher_does_not_send_when_unchanged() {
let _guard = theme_cache::test_lock()
.lock()
@@ -378,11 +380,11 @@ mod tests {
set_mock(Some(SystemAppearance::Dark));
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
// Wait longer than the poll interval — no change should occur.
// Wait longer than the poll interval — Unchanged should occur.
let result =
tokio::time::timeout(std::time::Duration::from_millis(200), watcher.changed()).await;
// Should timeout because appearance didn't change.
// Should timeout because appearance stayed the same.
assert!(
result.is_err(),
"expected timeout — no change should be emitted"
@@ -392,16 +394,17 @@ mod tests {
}
#[tokio::test]
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
// Deliberate: theme_cache::test_lock() serializes mock access.
#[allow(clippy::await_holding_lock)]
async fn watcher_detects_recovery_from_failure() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(None); // Initially detection fails
// Initially detection fails
set_mock(None);
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
assert_eq!(watcher.current(), None);
// Now detection succeeds.
set_mock(Some(SystemAppearance::Dark));
tokio::time::timeout(std::time::Duration::from_secs(2), watcher.changed())
@@ -4,7 +4,7 @@
//! the terminal's own canvas it can land dark-on-dark or light-on-light
//! (e.g. macOS in Light Mode + a dark terminal profile). Polarity detection
//! is not reliable either: OS appearance and OSC 11 both disagree with the
//! actual canvas in edge cases and can change mid-session. Terminal
//! actual canvas in edge cases and can differ mid-session. Terminal
//! profiles, however, tune their **default** fg/bg to be legible against
//! their own background — this is how `git` and `ls` stay readable on any
//! terminal — so a palette built from `Reset` (body) + sparse named ANSI-16
@@ -18,30 +18,32 @@ const fn rgb(r: u8, g: u8, b: u8) -> Color {
#[allow(dead_code)]
pub mod palette {
use super::*;
pub const BG: Color = rgb(26, 27, 38); // #1a1b26 - Night
pub const BG_DARK: Color = rgb(22, 22, 30); // #16161e
pub const BG_HIGHLIGHT: Color = rgb(41, 46, 66); // #292e42
pub const BG_STORM: Color = rgb(36, 40, 59); // #24283b - Storm
pub const BG_STORM_DARK: Color = rgb(31, 35, 53); // #1f2335
pub const FG: Color = rgb(192, 202, 245); // #c0caf5
pub const FG_DARK: Color = rgb(169, 177, 214); // #a9b1d6
pub const FG_GUTTER: Color = rgb(59, 66, 97); // #3b4261
pub const COMMENT: Color = rgb(86, 95, 137); // #565f89
pub const DARK3: Color = rgb(84, 92, 126); // #545c7e
pub const DARK5: Color = rgb(115, 122, 162); // #737aa2
pub const BLUE: Color = rgb(122, 162, 247); // #7aa2f7
pub const BLUE0: Color = rgb(61, 89, 161); // #3d59a1
pub const BLUE1: Color = rgb(42, 195, 222); // #2ac3de
pub const CYAN: Color = rgb(125, 207, 255); // #7dcfff
pub const GREEN: Color = rgb(158, 206, 106); // #9ece6a
pub const GREEN1: Color = rgb(115, 218, 202); // #73daca
pub const MAGENTA: Color = rgb(187, 154, 247); // #bb9af7
pub const ORANGE: Color = rgb(255, 158, 100); // #ff9e64
pub const PURPLE: Color = rgb(157, 124, 216); // #9d7cd8
pub const RED: Color = rgb(247, 118, 142); // #f7768e
pub const RED1: Color = rgb(219, 75, 75); // #db4b4b
pub const TEAL: Color = rgb(26, 188, 156); // #1abc9c
pub const YELLOW: Color = rgb(224, 175, 104); // #e0af68
// #1a1b26 - Night
pub const BG: Color = rgb(26, 27, 38);
pub const BG_DARK: Color = rgb(22, 22, 30);
pub const BG_HIGHLIGHT: Color = rgb(41, 46, 66);
// #24283b - Storm
pub const BG_STORM: Color = rgb(36, 40, 59);
pub const BG_STORM_DARK: Color = rgb(31, 35, 53);
pub const FG: Color = rgb(192, 202, 245);
pub const FG_DARK: Color = rgb(169, 177, 214);
pub const FG_GUTTER: Color = rgb(59, 66, 97);
pub const COMMENT: Color = rgb(86, 95, 137);
pub const DARK3: Color = rgb(84, 92, 126);
pub const DARK5: Color = rgb(115, 122, 162);
pub const BLUE: Color = rgb(122, 162, 247);
pub const BLUE0: Color = rgb(61, 89, 161);
pub const BLUE1: Color = rgb(42, 195, 222);
pub const CYAN: Color = rgb(125, 207, 255);
pub const GREEN: Color = rgb(158, 206, 106);
pub const GREEN1: Color = rgb(115, 218, 202);
pub const MAGENTA: Color = rgb(187, 154, 247);
pub const ORANGE: Color = rgb(255, 158, 100);
pub const PURPLE: Color = rgb(157, 124, 216);
pub const RED: Color = rgb(247, 118, 142);
pub const RED1: Color = rgb(219, 75, 75);
pub const TEAL: Color = rgb(26, 188, 156);
pub const YELLOW: Color = rgb(224, 175, 104);
}
use palette::*;
@@ -53,8 +55,10 @@ pub struct Theme {
pub bg_light: Color,
pub bg_dark: Color,
pub bg_highlight: Color,
pub bg_hover: Color, // Mouse hover row in dropdowns — between bg_highlight and bg_visual
pub bg_terminal: Color, // For terminal output blocks (currently unused, using bg_dark instead)
// Mouse hover row in dropdowns — between bg_highlight and bg_visual
pub bg_hover: Color,
// For terminal output blocks (currently unused, using bg_dark instead)
pub bg_terminal: Color,
// Accent colors (for vertical lines)
pub accent_user: Color,
@@ -64,8 +68,10 @@ pub struct Theme {
pub accent_system: Color,
pub accent_error: Color,
pub accent_success: Color,
pub accent_running: Color, // For tools that are currently running
pub accent_skill: Color, // For skill invocations (slash command skills)
// For tools that are currently running
pub accent_running: Color,
// For skill invocations (slash command skills)
pub accent_skill: Color,
// Text colors
pub text_primary: Color,
@@ -74,30 +80,42 @@ pub struct Theme {
// Gray scale (dim → medium → bright)
// Every theme defines these three; they provide a consistent hierarchy
// for secondary/meta text across all themes.
pub gray_dim: Color, // Dimmest — meta punctuation (`$`, `(+N/-M)`, etc.)
pub gray: Color, // Medium — muted text, comments, collapsed content
pub gray_bright: Color, // Brightest — tool accents, secondary labels
// Dimmest — meta punctuation (`$`, `(+N/-M)`, etc.)
pub gray_dim: Color,
// Medium — muted text, comments, collapsed content
pub gray: Color,
// Brightest — tool accents, secondary labels
pub gray_bright: Color,
// Semantic colors
pub command: Color, // Yellow for shell commands
pub path: Color, // Orange for file paths
pub running: Color, // Cyan for running indicator
pub warning: Color, // Yellow/amber for warnings
// Yellow for shell commands
pub command: Color,
// Orange for file paths
pub path: Color,
// Cyan for running indicator
pub running: Color,
// Yellow/amber for warnings
pub warning: Color,
// Search
pub fuzzy_accent: Color, // Highlight color for fuzzy search matches
// Highlight color for fuzzy search matches
pub fuzzy_accent: Color,
// Plan mode
pub accent_plan: Color, // Golden accent for plan mode indicator
// Golden accent for plan mode indicator
pub accent_plan: Color,
// Context-window overhead category (context info block)
pub accent_verify: Color, // Violet accent — distinct from plan gold and feedback teal
// Violet accent — distinct from plan gold and feedback teal
pub accent_verify: Color,
// Feedback mode
pub accent_feedback: Color, // Teal/green accent for feedback mode
// Teal/green accent for feedback mode
pub accent_feedback: Color,
// Remember mode
pub accent_remember: Color, // Green accent for # remember mode
// Green accent for # remember mode
pub accent_remember: Color,
// Selection
pub selection_border: Color,
@@ -106,7 +124,8 @@ pub struct Theme {
pub prompt_border_active: Color,
// Prompt info
pub accent_model: Color, // Model name in prompt info line
// Model name in prompt info line
pub accent_model: Color,
// Scrollbar
pub scrollbar_bg: Color,
@@ -132,25 +151,39 @@ pub struct Theme {
// blocks, inline code, links, etc. These default to the corresponding
// top-level theme colors but can be overridden per-theme to customise
// markdown appearance independently.
pub md_heading_h1: Color, // H1 headings
pub md_heading_h1_mod: Modifier, // H1 extra effects
pub md_heading_h2: Color, // H2 headings, task unchecked, tables
pub md_heading_h2_mod: Modifier, // H2 extra effects
pub md_heading_h3: Color, // H3 headings, code language tag
pub md_heading_h3_mod: Modifier, // H3 extra effects
pub md_heading_h4: Color, // H4 headings
pub md_heading_h4_mod: Modifier, // H4 extra effects
pub md_heading_h5: Color, // H5 headings, link titles
pub md_heading_h5_mod: Modifier, // H5 extra effects
pub md_heading_h6: Color, // H6 headings
pub md_heading_h6_mod: Modifier, // H6 extra effects
pub md_code: Color, // Inline code, code block delimiters
pub md_task_checked: Color, // Task checked
pub md_task_unchecked: Color, // Task unchecked
pub md_muted: Color, // Blockquotes, list items, rules, links
pub md_code_bg: Color, // Code block background
pub md_text: Color, // Default body text (plain paragraphs, strong, emphasis)
pub link_fg: Color, // Clickable link text color
pub md_heading_h1: Color,
// H1 extra effects
pub md_heading_h1_mod: Modifier,
// H2 headings, task unchecked, tables
pub md_heading_h2: Color,
// H2 extra effects
pub md_heading_h2_mod: Modifier,
// H3 headings, code language tag
pub md_heading_h3: Color,
// H3 extra effects
pub md_heading_h3_mod: Modifier,
pub md_heading_h4: Color,
// H4 extra effects
pub md_heading_h4_mod: Modifier,
// H5 headings, link titles
pub md_heading_h5: Color,
// H5 extra effects
pub md_heading_h5_mod: Modifier,
pub md_heading_h6: Color,
// H6 extra effects
pub md_heading_h6_mod: Modifier,
// Inline code, code block delimiters
pub md_code: Color,
pub md_task_checked: Color,
pub md_task_unchecked: Color,
// Blockquotes, list items, rules, links
pub md_muted: Color,
// Code block background
pub md_code_bg: Color,
// Default body text (plain paragraphs, strong, emphasis)
pub md_text: Color,
// Clickable link text color
pub link_fg: Color,
}
impl Theme {
@@ -172,7 +205,7 @@ impl Theme {
accent_error: RED,
accent_success: GREEN,
accent_running: MAGENTA,
accent_skill: rgb(100, 180, 170), // Muted teal
accent_skill: rgb(100, 180, 170),
text_primary: FG,
text_secondary: FG_DARK,
@@ -188,17 +221,24 @@ impl Theme {
fuzzy_accent: BLUE,
accent_plan: rgb(230, 180, 50), // #E6B432 — golden
// #E6B432 — golden
accent_plan: rgb(230, 180, 50),
accent_verify: MAGENTA, // #bb9af7 — violet (distinct from plan / feedback)
// #bb9af7 — violet (distinct from plan / feedback)
accent_verify: MAGENTA,
accent_feedback: GREEN1, // #73daca — warm teal/green
// #73daca — warm teal/green
accent_feedback: GREEN1,
accent_remember: Color::Rgb(139, 195, 74), // #8BC34A — Material Design light green
// #8BC34A — Material Design light green
accent_remember: Color::Rgb(139, 195, 74),
selection_border: rgb(58, 72, 115), // #3A4873 — muted tokyonight blue
prompt_border: rgb(60, 75, 120), // #323E64 — dimmer prompt chrome
prompt_border_active: rgb(75, 92, 140), // #4B5C8C — brighter when focused
// #3A4873 — muted tokyonight blue
selection_border: rgb(58, 72, 115),
// #323E64 — dimmer prompt chrome
prompt_border: rgb(60, 75, 120),
// #4B5C8C — brighter when focused
prompt_border_active: rgb(75, 92, 140),
hover_border: rgb(55, 58, 80),
accent_model: TEAL,
@@ -213,7 +253,8 @@ impl Theme {
diff_equal_fg: COMMENT,
diff_gutter_fg: COMMENT,
bg_visual: rgb(40, 52, 87), // #283457 — blue-tinted selection bg
// #283457 — blue-tinted selection bg
bg_visual: rgb(40, 52, 87),
paste_bg: BG_STORM_DARK,
paste_fg: FG_DARK,
@@ -239,7 +280,7 @@ impl Theme {
md_muted: COMMENT,
md_code_bg: BG_HIGHLIGHT,
md_text: FG,
link_fg: BLUE, // #7aa2f7
link_fg: BLUE,
}
}
+3 -3
View File
@@ -262,12 +262,12 @@ mod tests {
fn time_ago_days() {
assert_eq!(format_time_ago(Duration::from_secs(86400)), "1d");
assert_eq!(format_time_ago(Duration::from_secs(172800)), "2d");
assert_eq!(format_time_ago(Duration::from_secs(2_592_000 - 1)), "29d"); // just under 30d
assert_eq!(format_time_ago(Duration::from_secs(2_592_000 - 1)), "29d");
}
#[test]
fn time_ago_months() {
assert_eq!(format_time_ago(Duration::from_secs(2_592_000)), "1mo"); // 30d
assert_eq!(format_time_ago(Duration::from_secs(2_592_000)), "1mo");
assert_eq!(format_time_ago(Duration::from_secs(5_184_000)), "2mo");
// 359d is still 11mo (359/30=11); 360d would be 12mo.
assert_eq!(format_time_ago(Duration::from_secs(359 * 86400)), "11mo");
@@ -275,7 +275,7 @@ mod tests {
#[test]
fn time_ago_years() {
assert_eq!(format_time_ago(Duration::from_secs(31_536_000)), "1y"); // 365d
assert_eq!(format_time_ago(Duration::from_secs(31_536_000)), "1y");
assert_eq!(format_time_ago(Duration::from_secs(63_072_000)), "2y");
}