use koda_core::tools::{ToolEffect, classify_tool};
use ratatui::style::{Color, Modifier, Style};
pub const STATUS_SUCCESS: Style = Style::new().fg(Color::Green);
pub const STATUS_ERROR: Style = Style::new().fg(Color::Red);
pub const STATUS_WARNING: Style = Style::new().fg(Color::Yellow);
pub const STATUS_INFO: Style = Style::new().fg(Color::Cyan);
pub const DIM: Style = Style::new().fg(Color::DarkGray);
pub const BOLD: Style = Style::new().add_modifier(Modifier::BOLD);
pub const TOOL_PREFIX: Style = Style::new().fg(Color::DarkGray);
pub const CONTENT_READ: Style = Style::new().fg(Color::Rgb(198, 200, 209));
pub const CONTENT_WRITE: Style = Style::new().fg(Color::DarkGray);
const ACCENT_READ: Color = Color::Cyan; const ACCENT_WRITE: Color = Color::Rgb(255, 191, 0); const ACCENT_DELETE: Color = Color::Red;
const ACCENT_BASH: Color = Color::Rgb(255, 165, 0); const ACCENT_NETWORK: Color = Color::Blue;
const ACCENT_AGENT: Color = Color::Magenta;
pub const ACCENT_AMBER: Style = Style::new().fg(Color::Rgb(255, 191, 0));
pub const ACCENT_MAGENTA: Style = Style::new().fg(Color::Magenta);
pub fn tool_dot(name: &str) -> Style {
Style::new().fg(tool_dot_color(name))
}
pub fn tool_dot_color(name: &str) -> Color {
match name {
"WebFetch" | "WebSearch" => return ACCENT_NETWORK,
"Task" | "Agent" | "InvokeAgent" => return ACCENT_AGENT,
"Bash" => return ACCENT_BASH,
"Delete" => return ACCENT_DELETE,
_ => {}
}
match classify_tool(name) {
ToolEffect::ReadOnly => ACCENT_READ,
ToolEffect::RemoteAction => ACCENT_NETWORK,
ToolEffect::LocalMutation => ACCENT_WRITE,
ToolEffect::Destructive => ACCENT_DELETE,
}
}
pub fn content_style_for(tool_name: &str, is_stderr: bool) -> Style {
if is_stderr {
STATUS_ERROR
} else if matches!(classify_tool(tool_name), ToolEffect::ReadOnly) {
CONTENT_READ
} else {
CONTENT_WRITE
}
}
pub const PATH: Style = Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED);
pub const LINENO: Style = Style::new().fg(Color::Yellow);
pub const MATCH_HIT: Style = Style::new()
.fg(Color::Rgb(255, 191, 0))
.add_modifier(Modifier::BOLD);
pub const DIRECTORY: Style = Style::new().add_modifier(Modifier::BOLD);
pub fn style_for_extension(ext: &str) -> Style {
match ext {
"rs" | "py" | "js" | "ts" | "tsx" | "jsx" | "go" | "rb" | "java" | "c" | "cpp" | "h"
| "cs" | "swift" | "kt" | "scala" | "clj" | "ex" | "exs" | "ml" | "hs" | "lua" | "php"
| "pl" | "sh" | "bash" | "zsh" => Style::new().fg(Color::Green),
"toml" | "yaml" | "yml" | "json" | "json5" | "xml" | "ini" | "cfg" | "conf" | "env"
| "properties" => Style::new().fg(Color::Yellow),
"md" | "txt" | "rst" | "adoc" | "tex" => Style::new().fg(Color::White),
"lock" | "sum" | "min" => Style::new().fg(Color::DarkGray),
_ => Style::new().fg(Color::Reset),
}
}
pub const WARM_TITLE: Style = Style::new()
.fg(Color::Rgb(229, 192, 123))
.add_modifier(Modifier::BOLD);
pub const WARM_ACCENT: Style = Style::new().fg(Color::Rgb(209, 154, 102));
pub const WARM_MUTED: Style = Style::new().fg(Color::Rgb(124, 111, 100));
pub const WARM_INFO: Style = Style::new().fg(Color::Rgb(198, 165, 106));
pub fn syntax_highlight_enabled() -> bool {
use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var("KODA_SYNTAX_HIGHLIGHT")
.map(|v| {
!matches!(
v.to_ascii_lowercase().as_str(),
"off" | "0" | "false" | "no"
)
})
.unwrap_or(true)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_dot_groups_read_only_tools() {
assert_eq!(tool_dot_color("Read"), ACCENT_READ);
assert_eq!(tool_dot_color("Grep"), ACCENT_READ);
assert_eq!(tool_dot_color("List"), ACCENT_READ);
assert_eq!(tool_dot_color("Glob"), ACCENT_READ);
}
#[test]
fn tool_dot_distinguishes_destructive_from_mutating() {
assert_eq!(tool_dot_color("Delete"), ACCENT_DELETE);
assert_eq!(tool_dot_color("Write"), ACCENT_WRITE);
assert_eq!(tool_dot_color("Edit"), ACCENT_WRITE);
}
#[test]
fn tool_dot_network_distinct_from_read() {
assert_eq!(tool_dot_color("WebFetch"), ACCENT_NETWORK);
assert_eq!(tool_dot_color("WebSearch"), ACCENT_NETWORK);
assert_ne!(tool_dot_color("WebFetch"), ACCENT_READ);
}
#[test]
fn content_style_uses_read_for_readonly_tools() {
assert_eq!(content_style_for("Read", false), CONTENT_READ);
assert_eq!(content_style_for("Grep", false), CONTENT_READ);
}
#[test]
fn content_style_uses_write_for_mutating_tools() {
assert_eq!(content_style_for("Bash", false), CONTENT_WRITE);
assert_eq!(content_style_for("Write", false), CONTENT_WRITE);
}
#[test]
fn content_style_stderr_always_wins() {
assert_eq!(content_style_for("Read", true), STATUS_ERROR);
assert_eq!(content_style_for("Bash", true), STATUS_ERROR);
}
#[test]
fn extension_style_buckets_source_files_consistently() {
let rs = style_for_extension("rs");
let py = style_for_extension("py");
assert_eq!(rs.fg, py.fg);
}
#[test]
fn extension_style_distinguishes_categories() {
let src = style_for_extension("rs").fg.unwrap();
let cfg = style_for_extension("toml").fg.unwrap();
let doc = style_for_extension("md").fg.unwrap();
let lock = style_for_extension("lock").fg.unwrap();
let colors = [src, cfg, doc, lock];
for i in 0..colors.len() {
for j in (i + 1)..colors.len() {
assert_ne!(colors[i], colors[j], "extension buckets collide");
}
}
}
#[test]
fn syntax_kill_switch_default_is_on() {
if std::env::var("KODA_SYNTAX_HIGHLIGHT").is_err() {
assert!(syntax_highlight_enabled());
}
}
}