diff --git a/.github/config-schema.json b/.github/config-schema.json index dfe639ff7..812e3ee24 100644 --- a/.github/config-schema.json +++ b/.github/config-schema.json @@ -60,7 +60,9 @@ "additionalProperties": { "type": "string" }, - "default": {} + "default": { + "claude-code": "$claude_model$git_branch$claude_context$claude_cost" + } }, "aws": { "$ref": "#/$defs/AwsConfig", @@ -182,6 +184,72 @@ "disabled": false } }, + "claude_context": { + "$ref": "#/$defs/ClaudeContextConfig", + "default": { + "format": "[$gauge $percentage]($style) ", + "symbol": "", + "gauge_width": 5, + "display": [ + { + "threshold": 0.0, + "style": "bold green", + "hidden": true + }, + { + "threshold": 30.0, + "style": "bold green", + "hidden": false + }, + { + "threshold": 60.0, + "style": "bold yellow", + "hidden": false + }, + { + "threshold": 80.0, + "style": "bold red", + "hidden": false + } + ], + "disabled": false + } + }, + "claude_cost": { + "$ref": "#/$defs/ClaudeCostConfig", + "default": { + "format": "[$symbol(\\$$cost)]($style) ", + "symbol": "πŸ’° ", + "display": [ + { + "threshold": 0.0, + "style": "bold green", + "hidden": true + }, + { + "threshold": 1.0, + "style": "bold yellow", + "hidden": false + }, + { + "threshold": 5.0, + "style": "bold red", + "hidden": false + } + ], + "disabled": false + } + }, + "claude_model": { + "$ref": "#/$defs/ClaudeModelConfig", + "default": { + "format": "[$symbol$model]($style) ", + "symbol": "πŸ€– ", + "style": "bold blue", + "model_aliases": {}, + "disabled": false + } + }, "cmake": { "$ref": "#/$defs/CMakeConfig", "default": { @@ -2233,6 +2301,148 @@ }, "additionalProperties": false }, + "ClaudeContextConfig": { + "type": "object", + "properties": { + "format": { + "type": "string", + "default": "[$gauge $percentage]($style) " + }, + "symbol": { + "type": "string", + "default": "" + }, + "gauge_width": { + "type": "integer", + "format": "uint8", + "minimum": 0, + "maximum": 255, + "default": 5 + }, + "display": { + "type": "array", + "items": { + "$ref": "#/$defs/ClaudeDisplayConfig" + }, + "default": [ + { + "threshold": 0.0, + "style": "bold green", + "hidden": true + }, + { + "threshold": 30.0, + "style": "bold green", + "hidden": false + }, + { + "threshold": 60.0, + "style": "bold yellow", + "hidden": false + }, + { + "threshold": 80.0, + "style": "bold red", + "hidden": false + } + ] + }, + "disabled": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + }, + "ClaudeDisplayConfig": { + "type": "object", + "properties": { + "threshold": { + "type": "number", + "format": "float", + "default": 0.0 + }, + "style": { + "type": "string", + "default": "bold green" + }, + "hidden": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + }, + "ClaudeCostConfig": { + "type": "object", + "properties": { + "format": { + "type": "string", + "default": "[$symbol(\\$$cost)]($style) " + }, + "symbol": { + "type": "string", + "default": "πŸ’° " + }, + "display": { + "type": "array", + "items": { + "$ref": "#/$defs/ClaudeDisplayConfig" + }, + "default": [ + { + "threshold": 0.0, + "style": "bold green", + "hidden": true + }, + { + "threshold": 1.0, + "style": "bold yellow", + "hidden": false + }, + { + "threshold": 5.0, + "style": "bold red", + "hidden": false + } + ] + }, + "disabled": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + }, + "ClaudeModelConfig": { + "type": "object", + "properties": { + "format": { + "type": "string", + "default": "[$symbol$model]($style) " + }, + "symbol": { + "type": "string", + "default": "πŸ€– " + }, + "style": { + "type": "string", + "default": "bold blue" + }, + "model_aliases": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "disabled": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + }, "CMakeConfig": { "type": "object", "properties": { diff --git a/docs/advanced-config/README.md b/docs/advanced-config/README.md index 93ac5176a..99ff8bd39 100644 --- a/docs/advanced-config/README.md +++ b/docs/advanced-config/README.md @@ -338,6 +338,316 @@ Note: Continuation prompts are only available in the following shells: continuation_prompt = 'β–Άβ–Ά ' ``` +## Statusline for Claude Code + +Starship supports displaying a custom statusline when running inside Claude Code, Anthropic's CLI tool for interactive coding with Claude. This statusline provides real-time information about your Claude session, including the model being used, context window usage, and session costs. + +For more information about the Claude Code statusline feature, see the [Claude Code statusline documentation](https://code.claude.com/docs/en/statusline). + +### Setup + +To use Starship as your Claude Code statusline: + +1. Run `/statusline` in Claude Code and ask it to configure Starship, or manually add the following to your `.claude/settings.json`: + +```json +{ + "statusLine": { + "type": "command", + "command": "starship statusline claude-code" + } +} +``` + +2. Customize the statusline appearance in your `~/.config/starship.toml` (see [Configuration](#configuration) below) + +### Overview + +When invoked with `starship statusline claude-code`, Starship receives Claude Code session data via stdin and renders a statusline using a dedicated profile named `claude-code`. + +The profile includes three specialized modules: + +- `claude_model`: Displays the current Claude model being used +- `claude_context`: Shows context window usage with a visual gauge +- `claude_cost`: Displays session cost and statistics + +The default profile format is: + +```toml +[profiles] +claude-code = "$claude_model$git_branch$claude_context$claude_cost" +``` + +### Configuration + +You can customize the Claude Code statusline by modifying the `claude-code` profile and individual module configurations in your `~/.config/starship.toml`: + +```toml +# ~/.config/starship.toml + +# Customize the claude-code profile +[profiles] +claude-code = "$claude_model$claude_context$claude_cost" + +# Configure individual modules +[claude_model] +format = "[$symbol$model]($style) " +symbol = "πŸ€– " +style = "bold blue" + +[claude_context] +format = "[$gauge $percentage]($style) " +gauge_width = 10 + +[claude_cost] +format = "[$symbol$cost]($style) " +symbol = "πŸ’° " +``` + +### Claude Model + +The `claude_model` module displays the current Claude model being used in the session. + +#### Options + +| Option | Default | Description | +| --------------- | ---------------------------- | ----------------------------------------------------------------------------------------- | +| `format` | `'[$symbol$model]($style) '` | The format for the module. | +| `symbol` | `'πŸ€– '` | The symbol shown before the model name. | +| `style` | `'bold blue'` | The style for the module. | +| `model_aliases` | `{}` | Map of model IDs or display names to shorter aliases. Checks ID first, then display name. | +| `disabled` | `false` | Disables the `claude_model` module. | + +#### Variables + +| Variable | Example | Description | +| -------- | ------------------- | ------------------------------------- | +| model | `Claude 3.5 Sonnet` | The display name of the current model | +| model_id | `claude-3-5-sonnet` | The model ID | +| symbol | | Mirrors the value of option `symbol` | +| style\* | | Mirrors the value of option `style` | + +\*: This variable can only be used as a part of a style string + +#### Examples + +```toml +# ~/.config/starship.toml + +# Basic customization +[claude_model] +format = "on [$symbol$model]($style) " +symbol = "🧠 " +style = "bold cyan" + +# Using model aliases for vendor-specific model names +# You can alias by model ID or display name +[claude_model.model_aliases] +# Alias by vendor model ID (e.g. AWS Bedrock) +"global.anthropic.claude-sonnet-4-5-20250929-v1:0" = "Sonnet 4.5" +# Alias by display name +"Claude Sonnet 4.5 (Vendor Proxy)" = "Sonnet" +``` + +### Claude Context + +The `claude_context` module displays context window usage as a percentage and visual gauge. The style automatically changes based on configurable thresholds. + +#### Options + +| Option | Default | Description | +| ---------------------- | --------------------------------- | -------------------------------------------------- | +| `format` | `'[$gauge $percentage]($style) '` | The format for the module. | +| `symbol` | `''` | The symbol shown before the gauge. | +| `gauge_width` | `5` | The width of the gauge in characters. | +| `gauge_full_symbol` | `'β–ˆ'` | The symbol used for filled segments of the gauge. | +| `gauge_partial_symbol` | `'β–’'` | The symbol used for partial segments of the gauge. | +| `gauge_empty_symbol` | `'β–‘'` | The symbol used for empty segments of the gauge. | +| `display` | [see below](#display) | Threshold and style configurations. | +| `disabled` | `false` | Disables the `claude_context` module. | + +##### Display + +The `display` option is an array of objects that define thresholds and styles for different usage levels. The module uses the style from the highest matching threshold or hides the module if `hidden` is `true`. + +| Option | Default | Description | +| ----------- | ------------ | ------------------------------------------------------------------------ | +| `threshold` | `0.0` | The minimum context windows usage percentage to match this configuration | +| `style` | `bold green` | The value of `style` if this display configuration is matched | +| `hidden` | `false` | Hide this module if this the configuration is matched. | + +```toml +[[claude_context.display]] +threshold = 0 +hidden = true + +[[claude_context.display]] +threshold = 30 +style = "bold green" + +[[claude_context.display]] +threshold = 60 +style = "bold yellow" + +[[claude_context.display]] +threshold = 80 +style = "bold red" +``` + +#### Variables + +| Variable | Example | Description | +| -------------------------- | ------- | ----------------------------------------------------- | +| gauge | `β–ˆβ–ˆβ–’β–‘β–‘` | Visual representation of context usage | +| percentage | `65%` | Context usage as a percentage | +| input_tokens | `45.2k` | Total input tokens in conversation | +| output_tokens | `12.3k` | Total output tokens in conversation | +| curr_input_tokens | `5.1k` | Input tokens from most recent API call | +| curr_output_tokens | `1.2k` | Output tokens from most recent API call | +| curr_cache_creation_tokens | `1.5k` | Cache creation tokens from most recent API call | +| curr_cache_read_tokens | `23.4k` | Cache read tokens from most recent API call | +| total_tokens | `200k` | Total context window size | +| symbol | | Mirrors the value of option `symbol` | +| style\* | | Mirrors the style from the matching display threshold | + +\*: This variable can only be used as a part of a style string + +#### Examples + +**Minimal gauge-only display** + +```toml +# ~/.config/starship.toml + +[claude_context] +format = "[$gauge]($style) " +gauge_width = 10 +``` + +**Detailed token information** + +```toml +# ~/.config/starship.toml + +[claude_context] +format = "[$percentage ($input_tokens in / $output_tokens out)]($style) " +``` + +**Custom gauge symbols** + +```toml +# ~/.config/starship.toml + +[claude_context] +gauge_full_symbol = "β–°" +gauge_partial_symbol = "" +gauge_empty_symbol = "β–±" +gauge_width = 10 +format = "[$gauge]($style) " +``` + +**Custom thresholds** + +```toml +# ~/.config/starship.toml + +[[claude_context.display]] +threshold = 0 +style = "bold green" + +[[claude_context.display]] +threshold = 50 +style = "bold yellow" + +[[claude_context.display]] +threshold = 75 +style = "bold orange" + +[[claude_context.display]] +threshold = 90 +style = "bold red" +``` + +### Claude Cost + +The `claude_cost` module displays the total cost of the current Claude Code session in USD. Like `claude_context`, it supports threshold-based styling. + +#### Options + +| Option | Default | Description | +| ---------- | -------------------------------- | ----------------------------------- | +| `format` | `'[$symbol(\\$$cost)]($style) '` | The format for the module. | +| `symbol` | `'πŸ’° '` | The symbol shown before the cost. | +| `display` | [see below](#display-1) | Threshold and style configurations. | +| `disabled` | `false` | Disables the `claude_cost` module. | + +##### Display + +The `display` option is an array of objects that define cost thresholds and styles. The module uses the style from the highest matching threshold or hides the module if `hidden` is `true`. + +| Option | Default | Description | +| ----------- | ------------ | ------------------------------------------------------------- | +| `threshold` | `0.0` | The minimum cost in USD to match this configuration | +| `style` | `bold green` | The value of `style` if this display configuration is matched | +| `hidden` | `false` | Hide this module if this configuration is matched. | + +**Default configuration:** + +```toml +[[claude_cost.display]] +threshold = 0.0 +hidden = true + +[[claude_cost.display]] +threshold = 1.0 +style = "bold yellow" + +[[claude_cost.display]] +threshold = 5.0 +style = "bold red" +``` + +#### Variables + +| Variable | Example | Description | +| ------------- | -------- | ----------------------------------------------------- | +| cost | `1.23` | Total session cost in USD (formatted to 2 decimals) | +| duration | `1m 30s` | Total session duration | +| api_duration | `45s` | Total API call duration | +| lines_added | `1.2k` | Total lines of code added | +| lines_removed | `500` | Total lines of code removed | +| symbol | | Mirrors the value of option `symbol` | +| style\* | | Mirrors the style from the matching display threshold | + +\*: This variable can only be used as a part of a style string + +#### Examples + +```toml +# ~/.config/starship.toml + +# Cost with code change statistics +[claude_cost] +format = "[$symbol$cost (+$lines_added -$lines_removed)]($style) " + +# Hide module until cost exceeds $0.10 +[[claude_cost.display]] +threshold = 0.0 +hidden = true + +[[claude_cost.display]] +threshold = 0.10 +style = "bold yellow" + +[[claude_cost.display]] +threshold = 2.0 +style = "bold red" + +# Show duration information +[claude_cost] +format = "[$symbol$cost ($duration)]($style) " +``` + ## Style Strings Style strings are a list of words, separated by whitespace. The words are not case sensitive (i.e. `bold` and `BoLd` are considered the same string). Each word can be one of the following: diff --git a/src/config.rs b/src/config.rs index c6880b706..254a56eda 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,8 @@ use crate::configs::Palette; use crate::context::Context; -use crate::serde_utils::{ValueDeserializer, ValueRef}; use crate::utils; +use crate::utils::serde::{ValueDeserializer, ValueRef}; use nu_ansi_term::Color; use serde::{ Deserialize, Deserializer, Serialize, de::Error as SerdeError, de::value::Error as ValueError, diff --git a/src/configs/claude_context.rs b/src/configs/claude_context.rs new file mode 100644 index 000000000..b6080f305 --- /dev/null +++ b/src/configs/claude_context.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Deserialize, Serialize)] +#[cfg_attr( + feature = "config-schema", + derive(schemars::JsonSchema), + schemars(deny_unknown_fields) +)] +#[serde(default)] +pub struct ClaudeContextConfig<'a> { + pub format: &'a str, + pub symbol: &'a str, + pub gauge_width: u8, + pub gauge_full_symbol: &'a str, + pub gauge_partial_symbol: &'a str, + pub gauge_empty_symbol: &'a str, + #[serde(borrow)] + pub display: Vec>, + pub disabled: bool, +} + +impl Default for ClaudeContextConfig<'_> { + fn default() -> Self { + Self { + format: "[$gauge $percentage]($style) ", + symbol: "", + gauge_width: 5, + gauge_full_symbol: "β–ˆ", + gauge_partial_symbol: "β–’", + gauge_empty_symbol: "β–‘", + display: vec![ + ClaudeDisplayConfig { + threshold: 0., + hidden: true, + ..Default::default() + }, + ClaudeDisplayConfig { + threshold: 30., + style: "bold green", + ..Default::default() + }, + ClaudeDisplayConfig { + threshold: 60., + style: "bold yellow", + ..Default::default() + }, + ClaudeDisplayConfig { + threshold: 80., + style: "bold red", + ..Default::default() + }, + ], + disabled: false, + } + } +} + +#[derive(Clone, Deserialize, Serialize)] +#[cfg_attr( + feature = "config-schema", + derive(schemars::JsonSchema), + schemars(deny_unknown_fields) +)] +#[serde(default)] +pub struct ClaudeDisplayConfig<'a> { + pub threshold: f32, + pub style: &'a str, + pub hidden: bool, +} + +impl Default for ClaudeDisplayConfig<'_> { + fn default() -> Self { + Self { + threshold: 0., + style: "bold green", + hidden: false, + } + } +} diff --git a/src/configs/claude_cost.rs b/src/configs/claude_cost.rs new file mode 100644 index 000000000..79f67db10 --- /dev/null +++ b/src/configs/claude_cost.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +use crate::configs::claude_context::ClaudeDisplayConfig; + +#[derive(Clone, Deserialize, Serialize)] +#[cfg_attr( + feature = "config-schema", + derive(schemars::JsonSchema), + schemars(deny_unknown_fields) +)] +#[serde(default)] +pub struct ClaudeCostConfig<'a> { + pub format: &'a str, + pub symbol: &'a str, + #[serde(borrow)] + pub display: Vec>, + pub disabled: bool, +} + +impl Default for ClaudeCostConfig<'_> { + fn default() -> Self { + Self { + format: "[$symbol(\\$$cost)]($style) ", + symbol: "πŸ’° ", + display: vec![ + ClaudeDisplayConfig { + threshold: 0.00, + hidden: true, + ..Default::default() + }, + ClaudeDisplayConfig { + threshold: 1.0, + style: "bold yellow", + ..Default::default() + }, + ClaudeDisplayConfig { + threshold: 5.0, + style: "bold red", + ..Default::default() + }, + ], + disabled: false, + } + } +} diff --git a/src/configs/claude_model.rs b/src/configs/claude_model.rs new file mode 100644 index 000000000..3601efdee --- /dev/null +++ b/src/configs/claude_model.rs @@ -0,0 +1,29 @@ +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Deserialize, Serialize)] +#[cfg_attr( + feature = "config-schema", + derive(schemars::JsonSchema), + schemars(deny_unknown_fields) +)] +#[serde(default)] +pub struct ClaudeModelConfig<'a> { + pub format: &'a str, + pub symbol: &'a str, + pub style: &'a str, + pub model_aliases: IndexMap, + pub disabled: bool, +} + +impl Default for ClaudeModelConfig<'_> { + fn default() -> Self { + Self { + format: "[$symbol$model]($style) ", + symbol: "πŸ€– ", + style: "bold blue", + model_aliases: IndexMap::new(), + disabled: false, + } + } +} diff --git a/src/configs/mod.rs b/src/configs/mod.rs index a5a5d4994..5a989fe2a 100644 --- a/src/configs/mod.rs +++ b/src/configs/mod.rs @@ -9,6 +9,9 @@ pub mod bun; pub mod c; pub mod cc; pub mod character; +pub mod claude_context; +pub mod claude_cost; +pub mod claude_model; pub mod cmake; pub mod cmd_duration; pub mod cobol; @@ -139,6 +142,12 @@ pub struct FullConfig<'a> { #[serde(borrow)] character: character::CharacterConfig<'a>, #[serde(borrow)] + claude_context: claude_context::ClaudeContextConfig<'a>, + #[serde(borrow)] + claude_cost: claude_cost::ClaudeCostConfig<'a>, + #[serde(borrow)] + claude_model: claude_model::ClaudeModelConfig<'a>, + #[serde(borrow)] cmake: cmake::CMakeConfig<'a>, #[serde(borrow)] cmd_duration: cmd_duration::CmdDurationConfig<'a>, diff --git a/src/configs/starship_root.rs b/src/configs/starship_root.rs index 2ed3a2eeb..b267e1a23 100644 --- a/src/configs/starship_root.rs +++ b/src/configs/starship_root.rs @@ -2,6 +2,13 @@ use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +pub fn default_profiles() -> IndexMap { + IndexMap::from_iter([( + "claude-code".to_string(), + "$claude_model$git_branch$claude_context$claude_cost".to_string(), + )]) +} + #[derive(Clone, Serialize, Deserialize, Debug)] #[cfg_attr( feature = "config-schema", @@ -22,7 +29,11 @@ pub struct StarshipRootConfig { #[serde(skip_serializing_if = "Option::is_none")] pub palette: Option, pub palettes: HashMap, - pub profiles: IndexMap, + #[serde(rename = "profiles")] + #[cfg_attr(feature = "config-schema", schemars(default = "default_profiles"))] + pub user_profiles: IndexMap, + #[serde(skip)] + pub internal_profiles: IndexMap, } pub type Palette = HashMap; @@ -146,7 +157,8 @@ impl Default for StarshipRootConfig { format: "$all".to_string(), right_format: String::new(), continuation_prompt: "[βˆ™](bright-black) ".to_string(), - profiles: Default::default(), + user_profiles: IndexMap::new(), + internal_profiles: default_profiles(), scan_timeout: 30, command_timeout: 500, add_newline: true, diff --git a/src/configure.rs b/src/configure.rs index 390667e09..0162f93e4 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -318,10 +318,7 @@ mod tests { use tempfile::TempDir; use toml_edit::Item; - use crate::{ - context::{Properties, Shell, Target}, - context_env::Env, - }; + use crate::context::{Env, Properties, Shell, Target}; use super::*; diff --git a/src/context.rs b/src/context.rs index ef56d1f81..60d690cd4 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,6 +1,5 @@ use crate::config::{ModuleConfig, StarshipConfig}; use crate::configs::StarshipRootConfig; -use crate::context_env::Env; use crate::module::Module; use crate::utils::{CommandOutput, PathExt, create_command, exec_timeout, read_file}; @@ -29,6 +28,11 @@ use std::thread; use std::time::{Duration, Instant}; use terminal_size::terminal_size; +pub use crate::utils::env::Env; +pub use crate::utils::statusline::{ + ClaudeCodeData, ContextWindow, CostInfo, CurrentUsage, ModelInfo, Workspace, +}; + /// Context contains data or common methods that may be used by multiple modules. /// The data contained within Context will be relevant to this particular rendering /// of the prompt. @@ -79,6 +83,9 @@ pub struct Context<'a> { /// Starship root config pub root_config: StarshipRootConfig, + /// Claude Code session data (when running as statusline) + pub claude_code_data: Option>, + /// Avoid issues with unused lifetimes when features are disabled _marker: PhantomData<&'a ()>, } @@ -180,6 +187,7 @@ impl<'a> Context<'a> { #[cfg(feature = "battery")] battery_info_provider: &crate::modules::BatteryInfoProviderImpl, root_config, + claude_code_data: None, _marker: PhantomData, } } @@ -193,6 +201,12 @@ impl<'a> Context<'a> { self } + /// Sets the Claude Code session data + pub fn with_claude_code_data(mut self, data: ClaudeCodeData) -> Self { + self.claude_code_data = Some(Box::new(data)); + self + } + // Tries to retrieve home directory from a table in testing mode or else retrieves it from the os pub fn get_home(&self) -> Option { home_dir(&self.env) diff --git a/src/lib.rs b/src/lib.rs index 21f1d91c5..82b06d39e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,6 @@ pub mod config; pub mod configs; pub mod configure; pub mod context; -pub mod context_env; pub mod formatter; pub mod init; pub mod logger; @@ -21,7 +20,6 @@ pub mod module; mod modules; pub mod print; mod segment; -mod serde_utils; mod utils; #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index bce9ff5c4..0c8b08ee8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,12 @@ fn generate_completions(shell: CompletionShell) { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum Statuslines { + #[clap(alias = "claude")] + ClaudeCode, +} + #[derive(Subcommand, Debug)] enum Commands { /// Create a pre-populated GitHub issue with information about your configuration @@ -129,6 +135,15 @@ enum Commands { }, /// Generate random session key Session, + /// Prints the statusline with a specific profile + Statusline { + /// The statusline provider to use + provider: Statuslines, + #[clap(long)] + profile: Option, + #[clap(flatten)] + properties: Properties, + }, /// Prints time in milliseconds #[clap(hide = true)] Time, @@ -275,6 +290,18 @@ fn main() { .map(char::from) .collect::() ), + Commands::Statusline { + provider, + profile, + properties, + } => { + let profile = profile.unwrap_or_else(|| match provider { + Statuslines::ClaudeCode => "claude-code".to_string(), + }); + + let target = Target::Profile(profile); + print::prompt_with_claude_code(properties, target); + } #[cfg(feature = "config-schema")] Commands::ConfigSchema => print::print_schema(), } diff --git a/src/module.rs b/src/module.rs index 01a16c8ae..5d0d80449 100644 --- a/src/module.rs +++ b/src/module.rs @@ -15,6 +15,9 @@ pub const ALL_MODULES: &[&str] = &[ "bun", "c", "character", + "claude_context", + "claude_cost", + "claude_model", "cmake", "cmd_duration", "cobol", diff --git a/src/modules/claude_context.rs b/src/modules/claude_context.rs new file mode 100644 index 000000000..771193e6c --- /dev/null +++ b/src/modules/claude_context.rs @@ -0,0 +1,419 @@ +use super::{Context, Module, ModuleConfig}; +use crate::configs::claude_context::ClaudeContextConfig; +use crate::formatter::StringFormatter; +use crate::utils::humanize_int; + +pub fn module<'a>(context: &'a Context) -> Option> { + let mut module = context.new_module("claude_context"); + let config = ClaudeContextConfig::try_load(module.config); + + if config.disabled { + return None; + } + + // Read Claude Code data from Context + let claude_data = context.claude_code_data.as_ref()?; + + let total_tokens = claude_data.context_window.context_window_size; + let percentage_float = claude_data.context_window.used_percentage.clamp(0.0, 100.0); + let percentage = percentage_float.round() as u8; + + // Determine style based on percentage + let display_style = config + .display + .iter() + .filter(|s| percentage_float >= s.threshold) + .max_by(|a, b| { + a.threshold + .partial_cmp(&b.threshold) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + if display_style.is_some_and(|s| s.hidden) { + return None; + } + + if let Some(display_style) = display_style { + let parsed = StringFormatter::new(config.format).and_then(|formatter| { + formatter + .map_meta(|variable, _| match variable { + "symbol" => Some(config.symbol), + _ => None, + }) + .map_style(|variable| match variable { + "style" => Some(Ok(display_style.style)), + _ => None, + }) + .map(|variable| match variable { + "gauge" => { + let filled_float = (percentage as f64 / 100.0) * config.gauge_width as f64; + let (filled_count, partial) = if !config.gauge_partial_symbol.is_empty() { + let full = filled_float.floor() as usize; + let rem = filled_float - full as f64; + // Show partial block if remainder is significant enough (> 0.25) + // but if it's very close to 1 (> 0.75), just round up to full (handled by empty_count check) + if rem >= 0.25 { + (full, true) + } else { + (full, false) + } + } else { + (filled_float.round() as usize, false) + }; + + let filled_count = filled_count.min(config.gauge_width as usize); + let partial_count = if partial && filled_count < config.gauge_width as usize + { + 1 + } else { + 0 + }; + let empty_count = (config.gauge_width as usize) + .saturating_sub(filled_count + partial_count); + + let gauge = config.gauge_full_symbol.repeat(filled_count) + + &config.gauge_partial_symbol.repeat(partial_count) + + &config.gauge_empty_symbol.repeat(empty_count); + Some(Ok(gauge)) + } + "percentage" => Some(Ok(format!("{percentage}%"))), + "input_tokens" => Some(Ok(humanize_int( + claude_data.context_window.total_input_tokens, + ))), + "output_tokens" => Some(Ok(humanize_int( + claude_data.context_window.total_output_tokens, + ))), + "curr_input_tokens" => Some(Ok(humanize_int( + claude_data.context_window.current_usage.input_tokens, + ))), + "curr_output_tokens" => Some(Ok(humanize_int( + claude_data.context_window.current_usage.output_tokens, + ))), + "curr_cache_creation_tokens" => Some(Ok(humanize_int( + claude_data + .context_window + .current_usage + .cache_creation_input_tokens, + ))), + "curr_cache_read_tokens" => Some(Ok(humanize_int( + claude_data + .context_window + .current_usage + .cache_read_input_tokens, + ))), + "total_tokens" => Some(Ok(humanize_int(total_tokens))), + _ => None, + }) + .parse(None, Some(context)) + }); + + module.set_segments(match parsed { + Ok(segments) => segments, + Err(error) => { + log::warn!("Error in module `claude_context`: {error}"); + return None; + } + }); + + Some(module) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use crate::test::ModuleRenderer; + use nu_ansi_term::Color; + + #[test] + fn test_without_data() { + let actual = ModuleRenderer::new("claude_context").collect(); + assert_eq!(actual, None); + } + + #[test] + fn test_disabled() { + let data = get_test_claude_data(50.0); + let actual = ModuleRenderer::new("claude_context") + .config(toml::toml! { + [claude_context] + disabled = true + }) + .claude_code_data(data) + .collect(); + assert_eq!(actual, None); + } + + #[test] + fn test_token_format_variables() { + let data = get_test_claude_data(0.0); + + let actual = ModuleRenderer::new("claude_context") + .config(toml::toml! { + [claude_context] + format = "[$input_tokens/$output_tokens/$total_tokens/$curr_input_tokens/$curr_output_tokens/$curr_cache_creation_tokens/$curr_cache_read_tokens]($style) " + [[claude_context.display]] + threshold = 0 + style = "bold green" + }) + .claude_code_data(data) + .collect(); + + assert_eq!( + actual, + Some(format!( + "{} ", + Color::Green.bold().paint("1k/500/200k/1k/500/1k/2k") + )), + ); + } + + #[test] + fn test_zero_total_tokens() { + let mut data = get_test_claude_data(0.0); + data.context_window.context_window_size = 0; + + let actual = ModuleRenderer::new("claude_context") + .config(toml::toml! { + [claude_context] + [[claude_context.display]] + threshold = 0 + style = "bold green" + }) + .claude_code_data(data) + .collect(); + + assert_eq!( + actual, + Some(format!("{} ", Color::Green.bold().paint("β–‘β–‘β–‘β–‘β–‘ 0%"))), + "zero context window size should render as 0%" + ); + } + + fn get_test_claude_data(used_percentage: f32) -> crate::context::ClaudeCodeData { + crate::context::ClaudeCodeData { + cwd: None, + model: crate::context::ModelInfo { + id: "claude-3-5-sonnet".to_string(), + display_name: "Claude 3.5 Sonnet".to_string(), + }, + context_window: crate::context::ContextWindow { + context_window_size: 200000, + total_input_tokens: 1000, + total_output_tokens: 500, + used_percentage, + current_usage: crate::context::CurrentUsage { + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 1000, + cache_read_input_tokens: 2000, + }, + }, + cost: None, + workspace: None, + } + } + + #[test] + fn test_render_with_data() { + let data = get_test_claude_data(50.0); + + let actual = ModuleRenderer::new("claude_context") + .config(toml::toml! { + [claude_context.display] + threshold = 0 + style = "bold green" + }) + .claude_code_data(data) + .collect(); + + let expected = Some(format!("{} ", Color::Green.bold().paint("β–ˆβ–ˆβ–’β–‘β–‘ 50%"))); + assert_eq!(actual, expected); + } + + #[test] + fn test_multiple_thresholds() { + let data_low = get_test_claude_data(25.0); + let data_medium = get_test_claude_data(65.0); + let data_high = get_test_claude_data(85.0); + + let config = toml::toml! { + [claude_context] + format = "[$gauge]($style) " + [[claude_context.display]] + threshold = 0 + style = "bold green" + [[claude_context.display]] + threshold = 60 + style = "bold yellow" + [[claude_context.display]] + threshold = 80 + style = "bold red" + }; + + let actual_low = ModuleRenderer::new("claude_context") + .config(config.clone()) + .claude_code_data(data_low) + .collect(); + let expected_low = Some(format!("{} ", Color::Green.bold().paint("β–ˆβ–’β–‘β–‘β–‘"))); + assert_eq!(actual_low, expected_low); + + let actual_medium = ModuleRenderer::new("claude_context") + .config(config.clone()) + .claude_code_data(data_medium) + .collect(); + let expected_medium = Some(format!("{} ", Color::Yellow.bold().paint("β–ˆβ–ˆβ–ˆβ–’β–‘"))); + assert_eq!(actual_medium, expected_medium); + + let actual_high = ModuleRenderer::new("claude_context") + .config(config) + .claude_code_data(data_high) + .collect(); + let expected_high = Some(format!("{} ", Color::Red.bold().paint("β–ˆβ–ˆβ–ˆβ–ˆβ–’"))); + assert_eq!(actual_high, expected_high); + } + + #[test] + fn test_gauge_width() { + let data = get_test_claude_data(50.0); + + let actual = ModuleRenderer::new("claude_context") + .config(toml::toml! { + [claude_context] + gauge_width = 10 + [[claude_context.display]] + threshold = 0 + style = "bold green" + }) + .claude_code_data(data) + .collect(); + + let expected = Some(format!("{} ", Color::Green.bold().paint("β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘ 50%"))); + assert_eq!(actual, expected); + } + + #[test] + fn test_no_partial_symbol_rounds() { + let data = get_test_claude_data(50.0); + + let actual = ModuleRenderer::new("claude_context") + .config(toml::toml! { + [claude_context] + gauge_partial_symbol = "" + [[claude_context.display]] + threshold = 0 + style = "bold green" + }) + .claude_code_data(data) + .collect(); + + assert_eq!( + actual, + Some(format!("{} ", Color::Green.bold().paint("β–ˆβ–ˆβ–ˆβ–‘β–‘ 50%"))), + "empty gauge_partial_symbol should round to nearest full block" + ); + } + + #[test] + fn test_partial_not_shown_when_remainder_below_threshold() { + let data = get_test_claude_data(22.0); // rem=0.1 < 0.25 at gauge_width=5 + + let actual = ModuleRenderer::new("claude_context") + .config(toml::toml! { + [claude_context] + [[claude_context.display]] + threshold = 0 + style = "bold green" + }) + .claude_code_data(data) + .collect(); + + assert_eq!( + actual, + Some(format!("{} ", Color::Green.bold().paint("β–ˆβ–‘β–‘β–‘β–‘ 22%"))), + "partial block should not appear when remainder < 0.25" + ); + } + + #[test] + fn test_full_gauge_suppresses_partial() { + let data = get_test_claude_data(100.0); + + let actual = ModuleRenderer::new("claude_context") + .config(toml::toml! { + [claude_context] + [[claude_context.display]] + threshold = 0 + style = "bold red" + }) + .claude_code_data(data) + .collect(); + + assert_eq!( + actual, + Some(format!("{} ", Color::Red.bold().paint("β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 100%"))), + "fully filled gauge should show no partial or empty blocks" + ); + } + + #[test] + fn test_hidden_when_below_threshold() { + let data = get_test_claude_data(25.0); // below default threshold of 30 + + let actual = ModuleRenderer::new("claude_context") + .claude_code_data(data) + .collect(); + + assert_eq!( + actual, None, + "module should be hidden below the 30% threshold" + ); + } + + #[test] + fn test_hidden_when_no_display_matches() { + let data = get_test_claude_data(10.0); + + let actual = ModuleRenderer::new("claude_context") + .config(toml::toml! { + [claude_context] + [[claude_context.display]] + threshold = 50 + style = "bold green" + }) + .claude_code_data(data) + .collect(); + + assert_eq!( + actual, None, + "module should be hidden when no display entry matches" + ); + } + + #[test] + fn test_partial_gauge_symbols() { + let data = get_test_claude_data(27.5); + + let actual = ModuleRenderer::new("claude_context") + .config(toml::toml! { + [claude_context] + gauge_full_symbol = "f" + gauge_partial_symbol = "p" + gauge_empty_symbol = "e" + gauge_width = 10 + [[claude_context.display]] + threshold = 0 + style = "bold green" + }) + .claude_code_data(data) + .collect(); + + // 27.5% of 10 is 2.75. + // Full: 2 (floor of 2.75) + // Rem: 0.75 -> >= 0.25 so partial is true + // Gauge: 2 full + 1 partial + 7 empty = 10 total + let expected = Some(format!("{} ", Color::Green.bold().paint("ffpeeeeeee 28%"))); + assert_eq!(actual, expected); + } +} diff --git a/src/modules/claude_cost.rs b/src/modules/claude_cost.rs new file mode 100644 index 000000000..cfe3a2a69 --- /dev/null +++ b/src/modules/claude_cost.rs @@ -0,0 +1,262 @@ +use super::{Context, Module, ModuleConfig}; +use crate::configs::claude_cost::ClaudeCostConfig; +use crate::formatter::StringFormatter; +use crate::utils::{humanize_int, render_time}; + +pub fn module<'a>(context: &'a Context) -> Option> { + let mut module = context.new_module("claude_cost"); + let config = ClaudeCostConfig::try_load(module.config); + + if config.disabled { + return None; + } + + // Read Claude Code data from Context + let claude_data = context.claude_code_data.as_ref()?; + let cost_info = claude_data.cost.as_ref()?; + let total_cost = cost_info.total_cost_usd; + + let display_style = config + .display + .iter() + .filter(|s| total_cost >= (s.threshold as f64)) + .max_by(|a, b| { + a.threshold + .partial_cmp(&b.threshold) + .unwrap_or(std::cmp::Ordering::Equal) + }); + if display_style.is_some_and(|s| s.hidden) { + return None; + } + if let Some(display_style) = display_style { + let parsed = StringFormatter::new(config.format).and_then(|formatter| { + formatter + .map_meta(|variable, _| match variable { + "symbol" => Some(config.symbol), + _ => None, + }) + .map_style(|variable| match variable { + "style" => Some(Ok(display_style.style)), + _ => None, + }) + .map(|variable| match variable { + "cost" => Some(Ok(format!("{:.2}", total_cost))), + "duration" => Some(Ok(render_time(cost_info.total_duration_ms as u128, false))), + "api_duration" => Some(Ok(render_time( + cost_info.total_api_duration_ms as u128, + false, + ))), + "lines_added" => Some(Ok(humanize_int(cost_info.total_lines_added))), + "lines_removed" => Some(Ok(humanize_int(cost_info.total_lines_removed))), + _ => None, + }) + .parse(None, Some(context)) + }); + + module.set_segments(match parsed { + Ok(segments) => segments, + Err(error) => { + log::warn!("Error in module `claude_cost`: {error}"); + return None; + } + }); + + Some(module) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use crate::test::ModuleRenderer; + use nu_ansi_term::Color; + + #[test] + fn test_without_data() { + let actual = ModuleRenderer::new("claude_cost").collect(); + assert_eq!(actual, None); + } + + #[test] + fn test_disabled() { + let data = get_test_claude_data(1.0); + let actual = ModuleRenderer::new("claude_cost") + .config(toml::toml! { + [claude_cost] + disabled = true + }) + .claude_code_data(data) + .collect(); + assert_eq!(actual, None); + } + + #[test] + fn test_hidden_by_default_below_threshold() { + let data = get_test_claude_data(0.5); // below default threshold of 1.0 + + let actual = ModuleRenderer::new("claude_cost") + .claude_code_data(data) + .collect(); + + assert_eq!( + actual, None, + "module should be hidden below the $1.00 threshold" + ); + } + + #[test] + fn test_all_format_variables() { + let data = get_test_claude_data(1.234); + let actual = ModuleRenderer::new("claude_cost") + .config(toml::toml! { + [claude_cost] + format = "[$cost $duration $api_duration $lines_added $lines_removed]($style) " + [[claude_cost.display]] + threshold = 0.0 + style = "bold yellow" + }) + .claude_code_data(data) + .collect(); + + assert_eq!( + actual, + Some(format!( + "{} ", + Color::Yellow.bold().paint("1.23 1m0s 45s 1.2k 500") + )), + ); + } + + #[test] + fn test_api_duration_variable() { + let data = get_test_claude_data(1.234); // total_api_duration_ms = 45000 + + let actual = ModuleRenderer::new("claude_cost") + .config(toml::toml! { + [claude_cost] + format = "[$api_duration]($style) " + [[claude_cost.display]] + threshold = 0.0 + style = "bold yellow" + }) + .claude_code_data(data) + .collect(); + + assert_eq!( + actual, + Some(format!("{} ", Color::Yellow.bold().paint("45s"))), + ); + } + + fn get_test_claude_data(total_cost_usd: f64) -> crate::context::ClaudeCodeData { + crate::context::ClaudeCodeData { + cwd: None, + model: crate::context::ModelInfo { + id: "claude-3-5-sonnet".to_string(), + display_name: "Claude 3.5 Sonnet".to_string(), + }, + context_window: crate::context::ContextWindow { + context_window_size: 200000, + total_input_tokens: 1000, + total_output_tokens: 500, + used_percentage: 0.0, + current_usage: crate::context::CurrentUsage { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + cost: Some(crate::context::CostInfo { + total_cost_usd, + total_duration_ms: 60000, + total_api_duration_ms: 45000, + total_lines_added: 1200, + total_lines_removed: 500, + }), + workspace: None, + } + } + + #[test] + fn test_render_with_data() { + let data = get_test_claude_data(1.234); + + let actual = ModuleRenderer::new("claude_cost") + .config(toml::toml! { + [claude_cost] + format = "[$symbol(\\$$cost) (\\(+ $lines_added - $lines_removed\\))]($style) " + [[claude_cost.display]] + threshold = 0.0 + style = "bold yellow" + }) + .claude_code_data(data) + .collect(); + + let expected = Some(format!( + "{} ", + Color::Yellow.bold().paint("πŸ’° $1.23 (+ 1.2k - 500)") + )); + assert_eq!(actual, expected); + } + + #[test] + fn test_cost_below_threshold() { + let data = get_test_claude_data(0.5); + + let actual = ModuleRenderer::new("claude_cost") + .config(toml::toml! { + [claude_cost] + [[claude_cost.display]] + threshold = 1.0 + style = "bold yellow" + }) + .claude_code_data(data) + .collect(); + + assert_eq!(actual, None); + } + + #[test] + fn test_multiple_thresholds() { + let data_low = get_test_claude_data(0.5); + let data_medium = get_test_claude_data(2.5); + let data_high = get_test_claude_data(5.5); + + let config = toml::toml! { + [claude_cost] + format = "[$symbol(\\$$cost)]($style) " + [[claude_cost.display]] + threshold = 0.0 + style = "bold green" + [[claude_cost.display]] + threshold = 2.0 + style = "bold yellow" + [[claude_cost.display]] + threshold = 5.0 + style = "bold red" + }; + + let actual_low = ModuleRenderer::new("claude_cost") + .config(config.clone()) + .claude_code_data(data_low) + .collect(); + let expected_low = Some(format!("{} ", Color::Green.bold().paint("πŸ’° $0.50"))); + assert_eq!(actual_low, expected_low); + + let actual_medium = ModuleRenderer::new("claude_cost") + .config(config.clone()) + .claude_code_data(data_medium) + .collect(); + let expected_medium = Some(format!("{} ", Color::Yellow.bold().paint("πŸ’° $2.50"))); + assert_eq!(actual_medium, expected_medium); + + let actual_high = ModuleRenderer::new("claude_cost") + .config(config) + .claude_code_data(data_high) + .collect(); + let expected_high = Some(format!("{} ", Color::Red.bold().paint("πŸ’° $5.50"))); + assert_eq!(actual_high, expected_high); + } +} diff --git a/src/modules/claude_model.rs b/src/modules/claude_model.rs new file mode 100644 index 000000000..e06c3da2a --- /dev/null +++ b/src/modules/claude_model.rs @@ -0,0 +1,171 @@ +use super::{Context, Module, ModuleConfig}; +use crate::configs::claude_model::ClaudeModelConfig; +use crate::formatter::StringFormatter; + +pub fn module<'a>(context: &'a Context) -> Option> { + let mut module = context.new_module("claude_model"); + let config = ClaudeModelConfig::try_load(module.config); + + if config.disabled { + return None; + } + + // Read Claude Code data from Context + let claude_data = context.claude_code_data.as_ref()?; + + // Check if there's an alias for this model + let model_display = config + .model_aliases + .get(&claude_data.model.id) + .or_else(|| { + config + .model_aliases + .get(claude_data.model.display_name.as_str()) + }) + .copied() + .unwrap_or(&claude_data.model.display_name); + + let parsed = StringFormatter::new(config.format).and_then(|formatter| { + formatter + .map_meta(|variable, _| match variable { + "symbol" => Some(config.symbol), + _ => None, + }) + .map_style(|variable| match variable { + "style" => Some(Ok(config.style)), + _ => None, + }) + .map(|variable| match variable { + "model" => Some(Ok(model_display)), + "model_id" => Some(Ok(claude_data.model.id.as_str())), + _ => None, + }) + .parse(None, Some(context)) + }); + + module.set_segments(match parsed { + Ok(segments) => segments, + Err(error) => { + log::warn!("Error in module `claude_model`: {error}"); + return None; + } + }); + + Some(module) +} + +#[cfg(test)] +mod tests { + use crate::test::ModuleRenderer; + use nu_ansi_term::Color; + + #[test] + fn test_without_data() { + let actual = ModuleRenderer::new("claude_model").collect(); + assert_eq!(actual, None); + } + + #[test] + fn test_disabled() { + let data = crate::context::ClaudeCodeData { + cwd: None, + model: crate::context::ModelInfo { + id: "claude-3-5-sonnet".to_string(), + display_name: "Claude 3.5 Sonnet".to_string(), + }, + context_window: crate::context::ContextWindow::default(), + cost: None, + workspace: None, + }; + let actual = ModuleRenderer::new("claude_model") + .config(toml::toml! { + [claude_model] + disabled = true + }) + .claude_code_data(data) + .collect(); + assert_eq!(actual, None); + } + + #[test] + fn test_with_model_alias() { + let data = crate::context::ClaudeCodeData { + cwd: None, + model: crate::context::ModelInfo { + id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(), + display_name: "Claude Sonnet 4.5 (AWS Bedrock)".to_string(), + }, + context_window: crate::context::ContextWindow::default(), + cost: None, + workspace: None, + }; + + let actual = ModuleRenderer::new("claude_model") + .config(toml::toml! { + [claude_model] + symbol = "" + [claude_model.model_aliases] + "global.anthropic.claude-sonnet-4-5-20250929-v1:0" = "Sonnet 4.5" + }) + .claude_code_data(data) + .collect(); + + let expected = Some(format!("{} ", Color::Blue.bold().paint("Sonnet 4.5"))); + assert_eq!(actual, expected); + } + + #[test] + fn test_without_alias_uses_display_name() { + let data = crate::context::ClaudeCodeData { + cwd: None, + model: crate::context::ModelInfo { + id: "claude-3-5-sonnet".to_string(), + display_name: "Claude 3.5 Sonnet".to_string(), + }, + context_window: crate::context::ContextWindow::default(), + cost: None, + workspace: None, + }; + + let actual = ModuleRenderer::new("claude_model") + .config(toml::toml! { + [claude_model] + symbol = "" + }) + .claude_code_data(data) + .collect(); + + let expected = Some(format!( + "{} ", + Color::Blue.bold().paint("Claude 3.5 Sonnet") + )); + assert_eq!(actual, expected); + } + + #[test] + fn test_alias_by_display_name() { + let data = crate::context::ClaudeCodeData { + cwd: None, + model: crate::context::ModelInfo { + id: "some-long-vendor-specific-id".to_string(), + display_name: "Claude Sonnet 4.5 (Vendor Proxy)".to_string(), + }, + context_window: crate::context::ContextWindow::default(), + cost: None, + workspace: None, + }; + + let actual = ModuleRenderer::new("claude_model") + .config(toml::toml! { + [claude_model] + symbol = "" + [claude_model.model_aliases] + "Claude Sonnet 4.5 (Vendor Proxy)" = "Sonnet" + }) + .claude_code_data(data) + .collect(); + + let expected = Some(format!("{} ", Color::Blue.bold().paint("Sonnet"))); + assert_eq!(actual, expected); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index e3e7e503e..2946c1177 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -6,6 +6,9 @@ mod bun; mod c; mod cc; mod character; +mod claude_context; +mod claude_cost; +mod claude_model; mod cmake; mod cmd_duration; mod cobol; @@ -129,6 +132,9 @@ pub fn handle<'a>(module: &str, context: &'a Context) -> Option> { "bun" => bun::module(context), "c" => c::module(context), "character" => character::module(context), + "claude_context" => claude_context::module(context), + "claude_cost" => claude_cost::module(context), + "claude_model" => claude_model::module(context), "cmake" => cmake::module(context), "cmd_duration" => cmd_duration::module(context), "cobol" => cobol::module(context), @@ -263,6 +269,9 @@ pub fn description(module: &str) -> &'static str { "character" => { "A character (usually an arrow) beside where the text is entered in your terminal" } + "claude_context" => "Context window usage for Claude Code session", + "claude_cost" => "Cost info for Claude Code session", + "claude_model" => "AI model name for Claude Code session", "cmake" => "The currently installed version of CMake", "cmd_duration" => "How long the last command took to execute", "cobol" => "The currently installed version of COBOL/GNUCOBOL", diff --git a/src/modules/rust.rs b/src/modules/rust.rs index 3a5ee58a8..7614cd423 100644 --- a/src/modules/rust.rs +++ b/src/modules/rust.rs @@ -497,8 +497,7 @@ impl RustupSettings { #[cfg(test)] mod tests { - use crate::context::{Properties, Shell, Target}; - use crate::context_env::Env; + use crate::context::{Env, Properties, Shell, Target}; use std::io; use std::process::{ExitStatus, Output}; use std::sync::LazyLock; diff --git a/src/print.rs b/src/print.rs index 9d391a391..af312b424 100644 --- a/src/print.rs +++ b/src/print.rs @@ -73,6 +73,20 @@ pub fn prompt(args: Properties, target: Target) { let context = Context::new(args, target); let stdout = io::stdout(); let mut handle = stdout.lock(); + + write!(handle, "{}", get_prompt(&context)).unwrap(); +} + +pub fn prompt_with_claude_code(args: Properties, target: Target) { + let claude_data = serde_json::from_reader(io::stdin()) + .inspect_err(|e| log::error!("Failed to read Claude Code JSON from stdin: {e}")) + .unwrap_or_default(); + + let mut context = Context::new(args, target).with_claude_code_data(claude_data); + context.shell = Shell::Unknown; + let stdout = io::stdout(); + let mut handle = stdout.lock(); + write!(handle, "{}", get_prompt(&context)).unwrap(); } @@ -446,7 +460,11 @@ fn load_formatter_and_modules<'a>(context: &'a Context) -> (StringFormatter<'a>, let (left_format_str, right_format_str): (&str, &str) = match context.target { Target::Main | Target::Right => (&config.format, &config.right_format), Target::Profile(ref name) => { - if let Some(lf) = config.profiles.get(name) { + if let Some(lf) = config + .user_profiles + .get(name) + .or_else(|| config.internal_profiles.get(name)) + { (lf, "") } else { log::error!("Profile {name:?} not found"); @@ -812,4 +830,18 @@ mod test { assert_eq!(expected, actual); dir.close() } + + #[test] + fn test_prefer_user_profile() { + let mut context = default_context().set_config(toml::toml! { + add_newline = false + [profiles] + claude-code = "user profile" + }); + context.target = Target::Profile("claude-code".to_string()); + + let expected = "user profile"; + let actual = get_prompt(&context); + assert_eq!(expected, actual); + } } diff --git a/src/test/mod.rs b/src/test/mod.rs index 8e4ff643d..53b955f45 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -1,5 +1,4 @@ -use crate::context::{Context, Properties, Shell, Target}; -use crate::context_env::Env; +use crate::context::{Context, Env, Properties, Shell, Target}; use crate::logger::StarshipLogger; use crate::{ config::StarshipConfig, @@ -148,6 +147,11 @@ impl<'a> ModuleRenderer<'a> { self } + pub fn claude_code_data(mut self, data: crate::context::ClaudeCodeData) -> Self { + self.context.claude_code_data = Some(Box::new(data)); + self + } + #[cfg(feature = "battery")] pub fn battery_info_provider( mut self, diff --git a/src/context_env.rs b/src/utils/env.rs similarity index 100% rename from src/context_env.rs rename to src/utils/env.rs diff --git a/src/utils.rs b/src/utils/mod.rs similarity index 96% rename from src/utils.rs rename to src/utils/mod.rs index 57fbaaa46..edcc70219 100644 --- a/src/utils.rs +++ b/src/utils/mod.rs @@ -1,3 +1,7 @@ +pub mod env; +pub mod serde; +pub mod statusline; + use process_control::{ChildExt, Control}; use std::ffi::OsStr; use std::fmt::Debug; @@ -738,6 +742,34 @@ pub fn render_time(raw_millis: u128, show_millis: bool) -> String { } } +/// Formats an integer into a human-readable string using SI prefixes (k, M, G, T) +pub fn humanize_int(n: u64) -> String { + if n < 1000 { + return n.to_string(); + } + + let n = n as f64; + let units = ["k", "M", "G", "T", "P", "E"]; + let mut unit_idx = 0; + let mut val = n / 1000.0; + + while val >= 1000.0 && unit_idx < units.len() - 1 { + val /= 1000.0; + unit_idx += 1; + } + + if val < 10.0 { + let s = format!("{:.1}{}", val, units[unit_idx]); + if s.contains(".0") { + s.replace(".0", "") + } else { + s + } + } else { + format!("{:.0}{}", val, units[unit_idx]) + } +} + pub fn home_dir() -> Option { dirs::home_dir() } @@ -822,6 +854,18 @@ mod tests { assert_eq!(render_time(86_400_000_u128, false), "1d0h0m0s"); } + #[test] + fn test_humanize_int() { + assert_eq!(humanize_int(0), "0"); + assert_eq!(humanize_int(999), "999"); + assert_eq!(humanize_int(1000), "1k"); + assert_eq!(humanize_int(1200), "1.2k"); + assert_eq!(humanize_int(10000), "10k"); + assert_eq!(humanize_int(100000), "100k"); + assert_eq!(humanize_int(1000000), "1M"); + assert_eq!(humanize_int(1500000), "1.5M"); + } + #[test] fn exec_mocked_command() { let result = exec_cmd( diff --git a/src/serde_utils.rs b/src/utils/serde.rs similarity index 100% rename from src/serde_utils.rs rename to src/utils/serde.rs diff --git a/src/utils/statusline.rs b/src/utils/statusline.rs new file mode 100644 index 000000000..56a93dc2a --- /dev/null +++ b/src/utils/statusline.rs @@ -0,0 +1,58 @@ +/// Claude Code session data structures for statusline integration +use serde::Deserialize; + +/// Claude Code session data +#[derive(Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct ClaudeCodeData { + pub cwd: Option, + pub model: ModelInfo, + pub context_window: ContextWindow, + pub cost: Option, + pub workspace: Option, +} + +#[derive(Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct ModelInfo { + pub id: String, + pub display_name: String, +} + +#[derive(Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct ContextWindow { + pub context_window_size: u64, + pub total_input_tokens: u64, + pub total_output_tokens: u64, + /// Pre-computed usage percentage (0–100) provided by Claude Code + pub used_percentage: f32, + /// Usage of the most recent API call + pub current_usage: CurrentUsage, +} + +#[derive(Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct CurrentUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_creation_input_tokens: u64, + pub cache_read_input_tokens: u64, +} + +#[derive(Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct CostInfo { + pub total_cost_usd: f64, + pub total_duration_ms: u64, + pub total_api_duration_ms: u64, + pub total_lines_added: u64, + pub total_lines_removed: u64, +} + +#[derive(Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct Workspace { + pub current_dir: String, + pub project_dir: String, +}