Skip to content
127 changes: 111 additions & 16 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1995,32 +1995,84 @@ async fn run_interactive(
}
}

// CLAUDE_STATUS_COMMAND: optional external command whose stdout replaces the
// left-side status bar text. Polled every 500ms (debounced) in the main loop.
// The command is run in a background task; results flow through a channel.
let status_cmd_str = std::env::var("CLAUDE_STATUS_COMMAND").ok();
// Status line: optional external command whose stdout is shown in a status
// bar row. Configured via settings.json `statusLine`. The command receives
// session data as JSON on stdin and is polled at `pollIntervalMs` (default 5s).
// Backward compat: CLAUDE_STATUS_COMMAND env var is also checked.
let status_cmd_cfg = live_config.status_line.as_ref().map(|s| {
(s.command.clone(), s.poll_interval_ms.unwrap_or(5000))
}).or_else(|| {
std::env::var("CLAUDE_STATUS_COMMAND").ok().map(|c| (c, 500u16 as u64))
});
let (status_cmd_tx, mut status_cmd_rx) = mpsc::channel::<String>(4);
if let Some(ref cmd_str) = status_cmd_str {
// Shared session data for the polling task (updated from the main loop).
let session_data: Arc<std::sync::Mutex<Option<String>>> = Arc::default();
// Provide an initial empty payload so the status command has input on its
// very first run and does not block on or fail to parse an empty stdin.
*session_data.lock().unwrap() = Some(r#"{}"#.to_string());
let status_line_cancel = CancellationToken::new();
if let Some((ref cmd_str, poll_ms)) = status_cmd_cfg {
let cmd_str = cmd_str.clone();
let tx = status_cmd_tx.clone();
let sd = session_data.clone();
let cancel = status_line_cancel.clone();
tokio::spawn(async move {
// Clamp interval: minimum 1000ms, maximum 300000ms (5 minutes).
let interval_ms = poll_ms.clamp(1000, 300000);
// Cap captured output at 4 KB.
let max_output_bytes: usize = 4096;
// Wait briefly before first poll so the main loop has a chance to
// populate session_data with real values.
tokio::time::sleep(Duration::from_millis(500)).await;
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
// Run via shell so pipes/redirects in the command string work.
let input = sd.lock().unwrap().clone().unwrap_or_default();
let output = if cfg!(target_os = "windows") {
tokio::process::Command::new("cmd")
.args(["/C", &cmd_str])
.output()
.await
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
} else {
tokio::process::Command::new("sh")
.args(["-c", &cmd_str])
.output()
.await
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
};
if let Ok(out) = output {
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
let _ = tx.try_send(text);
if let Ok(mut child) = output {
use tokio::io::{AsyncWriteExt, AsyncReadExt};
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes()).await.ok();
drop(stdin);
}
let mut child_stdout = child.stdout.take();
// Timeout: kill child if it doesn't finish within interval_ms.
let timed_out = tokio::time::timeout(
Duration::from_millis(interval_ms),
child.wait(),
).await;
match timed_out {
Ok(Ok(_)) => {
let mut buf = Vec::with_capacity(max_output_bytes);
if let Some(ref mut stdout) = child_stdout {
stdout.read_to_end(&mut buf).await.ok();
}
let text = String::from_utf8_lossy(&buf[..buf.len().min(max_output_bytes)])
.trim().to_string();
let _ = tx.try_send(text);
}
Ok(Err(_)) => {}
Err(_elapsed) => {
let _ = child.start_kill();
let _ = child.wait().await;
}
}
}
tokio::select! {
_ = cancel.cancelled() => break,
_ = tokio::time::sleep(Duration::from_millis(interval_ms)) => {}
}
}
});
Expand Down Expand Up @@ -3636,8 +3688,50 @@ async fn run_interactive(
}
}

// Drain CLAUDE_STATUS_COMMAND results (most recent wins)
if status_cmd_str.is_some() {
// Update shared session data for status line polling task.
if status_cmd_cfg.is_some() {
let elapsed = app.session_start.elapsed().as_secs();
let ctx_size = app.context_window_size;
let used_pct = if ctx_size > 0 {
Some(app.context_used_tokens as f64 / ctx_size as f64 * 100.0)
} else {
None
};
let total_input = cost_tracker.input_tokens()
+ cost_tracker.cache_creation_tokens()
+ cost_tracker.cache_read_tokens();
let total_output = cost_tracker.output_tokens();
let json = serde_json::json!({
"session_id": tool_ctx.session_id,
"model": {
"display_name": app.model_name,
"id": app.model_name,
},
"workspace": {
"current_dir": app.current_dir,
},
"context_window": {
"total_input_tokens": total_input,
"total_output_tokens": total_output,
"context_window_size": ctx_size,
"current_usage": {
"input_tokens": cost_tracker.input_tokens(),
"output_tokens": total_output,
"cache_creation_input_tokens": cost_tracker.cache_creation_tokens(),
"cache_read_input_tokens": cost_tracker.cache_read_tokens(),
},
"used_percentage": used_pct,
"remaining_percentage": used_pct.map(|p| 100.0 - p),
},
"cost": {
"total_cost_usd": app.cost_usd,
},
"elapsed_seconds": elapsed,
});
*session_data.lock().unwrap() = Some(serde_json::to_string(&json).unwrap_or_default());
}
// Drain status line results (most recent wins)
if status_cmd_cfg.is_some() {
while let Ok(text) = status_cmd_rx.try_recv() {
app.status_line_override = if text.is_empty() { None } else { Some(text) };
}
Expand Down Expand Up @@ -4230,6 +4324,7 @@ async fn run_interactive(
if let Some(runtime) = bridge_runtime.take() {
runtime.cancel.cancel();
}
status_line_cancel.cancel();
restore_terminal(&mut terminal)?;
Ok(())
}
Expand Down
95 changes: 95 additions & 0 deletions src-rust/crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,7 @@ pub mod config {

/// Top-level configuration values, merged from CLI args + settings file + env.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub api_key: Option<String>,
pub model: Option<String>,
Expand Down Expand Up @@ -1114,6 +1115,95 @@ pub mod config {
/// Keyboard scrolling (PageUp/PageDown, etc.) is unaffected either way.
#[serde(default, rename = "mouseCapture", skip_serializing_if = "Option::is_none")]
pub mouse_capture: Option<bool>,
/// External status line command configuration. When set, the command is
/// polled periodically and its stdout is shown in a status bar row.
#[serde(default, rename = "statusLine", alias = "status_line", skip_serializing_if = "Option::is_none")]
pub status_line: Option<StatusLineConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusLineConfig {
/// Shell command to execute. Receives session JSON on stdin.
pub command: String,
/// Poll interval in milliseconds (default: 5000, clamped to 1000-300000 at use-site).
#[serde(
default,
rename = "pollIntervalMs",
alias = "poll_interval_ms",
skip_serializing_if = "Option::is_none"
)]
pub poll_interval_ms: Option<u64>,
/// Horizontal padding (in spaces) around the status text (default: 0).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub padding: Option<u16>,
/// Maximum number of lines the status output can occupy (default: 10).
#[serde(
default,
rename = "maxLines",
alias = "max_lines",
skip_serializing_if = "Option::is_none"
)]
pub max_lines: Option<u16>,
}

#[cfg(test)]
mod status_line_tests {
use super::*;
#[test]
fn deserialize_status_line_config() {
let json = r#"{"command": "date", "pollIntervalMs": 1000, "padding": 1}"#;
let sl: StatusLineConfig = serde_json::from_str(json).unwrap();
assert_eq!(sl.command, "date");
assert_eq!(sl.poll_interval_ms, Some(1000));
assert_eq!(sl.padding, Some(1));
assert_eq!(sl.max_lines, None);
}
#[test]
fn deserialize_status_line_config_with_max_lines() {
let json = r#"{"command": "date", "maxLines": 5}"#;
let sl: StatusLineConfig = serde_json::from_str(json).unwrap();
assert_eq!(sl.max_lines, Some(5));
}
#[test]
fn deserialize_status_line_in_settings() {
let json = r#"{"config": {"statusLine": {"command": "date '+%H:%M:%S'", "pollIntervalMs": 1000, "padding": 1}}}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
let sl = settings.config.status_line.unwrap();
assert_eq!(sl.command, "date '+%H:%M:%S'");
assert_eq!(sl.poll_interval_ms, Some(1000));
}
#[test]
fn project_cannot_override_global_status_line() {
let global = Settings {
config: Config {
status_line: Some(StatusLineConfig {
command: "global-cmd".into(),
poll_interval_ms: Some(5000),
padding: Some(1),
max_lines: None,
}),
..Config::default()
},
..Settings::default()
};
let project = Settings {
config: Config {
status_line: Some(StatusLineConfig {
command: "malicious-cmd".into(),
poll_interval_ms: Some(1000),
padding: None,
max_lines: None,
}),
..Config::default()
},
..Settings::default()
};
let merged = Settings::merge(global.clone(), project);
let sl = merged.config.status_line.unwrap();
assert_eq!(sl.command, "global-cmd", "project must not override global status_line command");
assert_eq!(sl.poll_interval_ms, Some(5000));
assert_eq!(sl.padding, Some(1));
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
Expand Down Expand Up @@ -1982,6 +2072,11 @@ pub mod config {
file_injection_enabled: over.config.file_injection_enabled || base.config.file_injection_enabled,
file_injection_max_size: if over.config.file_injection_max_size != 0 { over.config.file_injection_max_size } else { base.config.file_injection_max_size },
request_timeout_secs: over.config.request_timeout_secs.or(base.config.request_timeout_secs),
// SECURITY: only the user's global settings may define a status_line
// command. A project's settings file must NOT be able to inject an
// arbitrary shell command — otherwise cloning a malicious repo would
// grant immediate RCE via sh -c execution.
status_line: base.config.status_line,
};
Self {
config: merged_config,
Expand Down
25 changes: 25 additions & 0 deletions src-rust/crates/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,24 @@ impl App {
self.status_message = Some(format!("Fast mode {}.", status));
true
}
"cc-statusline" | "cc-status-line" => {
match &self.config.status_line {
Some(sl) => {
let interval = sl.poll_interval_ms.unwrap_or(5000);
let padding = sl.padding.unwrap_or(0);
self.status_message = Some(format!(
"External statusLine active · command: {} · interval: {}ms · padding: {}",
sl.command, interval, padding,
));
}
None => {
self.status_message = Some(
"No external statusLine configured. Set \"statusLine\": { \"command\": \"...\" } in ~/.claurst/settings.json".to_string(),
);
}
}
true
}
"plan" => {
use claurst_core::config::PermissionMode;
self.plan_mode = !self.plan_mode;
Expand Down Expand Up @@ -7311,6 +7329,13 @@ mod tests {
assert!(app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::NONE)));
}

#[test]
fn test_cc_statusline_slash_command() {
let mut app = make_app();
assert!(app.intercept_slash_command("cc-statusline"));
assert!(app.status_message.as_deref().map_or(false, |m| m.contains("No external statusLine configured")));
}

#[test]
fn test_mcp_subcommand_is_not_intercepted() {
let mut app = make_app();
Expand Down
Loading