use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::paths;
fn list_custom_widgets_in(dir: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let seen: HashSet<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let path = e.path();
if !path.is_file() {
return None;
}
let stem = path.file_stem()?.to_str()?.to_string();
let ext = path.extension().and_then(|x| x.to_str()).unwrap_or("");
if !matches!(ext, "sh" | "py" | "") {
return None;
}
Some(stem)
})
.collect();
let mut names: Vec<String> = seen.into_iter().collect();
names.sort();
names
}
pub fn list_custom_widgets() -> Vec<String> {
list_custom_widgets_in(&paths::widgets_dir())
}
#[derive(Debug, Clone, PartialEq)]
pub struct WidgetMeta {
pub name: String,
pub description: String,
pub components: Vec<String>,
pub has_compact: bool,
}
fn list_widget_metas_in(dir: &Path) -> Vec<WidgetMeta> {
list_custom_widgets_in(dir)
.into_iter()
.map(|name| {
let toml_path = dir.join(format!("{name}.toml"));
if let Ok(raw) = std::fs::read_to_string(&toml_path) {
if let Ok(table) = raw.parse::<toml::Table>() {
return WidgetMeta {
name: name.clone(),
description: table
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("Custom widget")
.to_string(),
components: table
.get("components")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
has_compact: table
.get("has_compact")
.and_then(|v| v.as_bool())
.unwrap_or(false),
};
}
}
WidgetMeta {
name,
description: "Custom widget".to_string(),
components: vec![],
has_compact: false,
}
})
.collect()
}
pub fn list_widget_metas() -> Vec<WidgetMeta> {
list_widget_metas_in(&paths::widgets_dir())
}
#[derive(Debug, Clone)]
pub struct OwnedThemeSlot {
pub key: String,
pub palette_role: Option<crate::theme::PaletteRole>,
}
#[derive(Debug, Clone)]
pub struct OwnedIconSlot {
pub key: String,
pub default_value: String,
}
#[derive(Debug, Clone)]
pub enum OwnedSettingType {
Int { min: Option<i64>, max: Option<i64> },
Float { min: Option<f64>, max: Option<f64> },
Bool,
Str,
Enum { options: Vec<String> },
}
#[derive(Debug, Clone)]
pub struct OwnedSettingSlot {
pub key: String,
pub label: String,
pub setting_type: OwnedSettingType,
pub default_value: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct OwnedWidgetMeta {
pub theme_slots: Vec<OwnedThemeSlot>,
pub icon_slots: Vec<OwnedIconSlot>,
pub setting_slots: Vec<OwnedSettingSlot>,
}
fn toml_to_json(v: &toml::Value) -> serde_json::Value {
match v {
toml::Value::String(s) => serde_json::Value::String(s.clone()),
toml::Value::Integer(n) => serde_json::Value::Number((*n).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
_ => serde_json::Value::Null,
}
}
fn palette_role_from_name(s: &str) -> Option<crate::theme::PaletteRole> {
crate::theme::PaletteRole::from_name(s)
}
fn legacy_field_to_role(s: &str) -> Option<crate::theme::PaletteRole> {
use crate::theme::PaletteRole;
match s {
"green" => Some(PaletteRole::Success),
"orange" | "yellow" => Some(PaletteRole::Warning),
"red" => Some(PaletteRole::Danger),
"dim" => Some(PaletteRole::Muted),
"lgray" => Some(PaletteRole::Subtle),
_ => None,
}
}
pub fn widget_meta(name: &str) -> Option<OwnedWidgetMeta> {
let dir = crate::paths::widgets_dir();
let script_exists = [name, &format!("{name}.sh")[..], &format!("{name}.py")[..]]
.iter()
.any(|f| dir.join(f).exists());
if !script_exists {
return None;
}
let toml_path = dir.join(format!("{name}.toml"));
let (theme_slots, icon_slots, setting_slots) = std::fs::read_to_string(&toml_path)
.ok()
.and_then(|raw| raw.parse::<toml::Table>().ok())
.map(|table| {
let ts = table
.get("theme")
.and_then(|v| v.as_table())
.map(|theme_table| {
theme_table
.iter()
.filter_map(|(key, val)| {
let sub = val.as_table()?;
let role = sub
.get("palette_role")
.and_then(|v| v.as_str())
.and_then(palette_role_from_name);
Some(OwnedThemeSlot {
key: key.clone(),
palette_role: role,
})
})
.collect::<Vec<_>>()
})
.or_else(|| {
table
.get("colors")
.and_then(|v| v.as_table())
.map(|colors_table| {
colors_table
.iter()
.filter_map(|(key, val)| {
let sub = val.as_table()?;
let role = sub
.get("theme_field")
.and_then(|v| v.as_str())
.and_then(legacy_field_to_role);
Some(OwnedThemeSlot {
key: key.clone(),
palette_role: role,
})
})
.collect::<Vec<_>>()
})
})
.unwrap_or_default();
let is = table
.get("icons")
.and_then(|v| v.as_table())
.map(|icons_table| {
icons_table
.iter()
.filter_map(|(key, val)| {
let sub = val.as_table()?;
Some(OwnedIconSlot {
key: key.clone(),
default_value: sub
.get("default_value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let ss = table
.get("settings")
.and_then(|v| v.as_table())
.map(|settings_table| {
settings_table
.iter()
.filter_map(|(key, val)| {
let sub = val.as_table()?;
let type_str =
sub.get("type").and_then(|v| v.as_str()).unwrap_or("string");
let label = sub
.get("label")
.and_then(|v| v.as_str())
.unwrap_or(key.as_str())
.to_string();
let setting_type = match type_str {
"int" => OwnedSettingType::Int {
min: sub.get("min").and_then(|v| v.as_integer()),
max: sub.get("max").and_then(|v| v.as_integer()),
},
"float" => OwnedSettingType::Float {
min: sub.get("min").and_then(|v| v.as_float()),
max: sub.get("max").and_then(|v| v.as_float()),
},
"bool" => OwnedSettingType::Bool,
"enum" => OwnedSettingType::Enum {
options: sub
.get("options")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
},
_ => OwnedSettingType::Str,
};
let default_value = sub
.get("default")
.map(toml_to_json)
.unwrap_or(serde_json::Value::Null);
Some(OwnedSettingSlot {
key: key.clone(),
label,
setting_type,
default_value,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
(ts, is, ss)
})
.unwrap_or_default();
Some(OwnedWidgetMeta {
theme_slots,
icon_slots,
setting_slots,
})
}
pub fn run_widget(name: &str, stdin_json: &str) -> Option<String> {
let out = run_widget_full(name, stdin_json)?;
let input: serde_json::Value = serde_json::from_str(stdin_json).unwrap_or_default();
let requested: Vec<String> = input
.get("config")
.and_then(|c| c.get("components"))
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let compact = input
.get("config")
.and_then(|c| c.get("compact"))
.and_then(|c| c.as_bool())
.unwrap_or(false);
let result = out.compose(&requested, compact);
if result.is_empty() {
None
} else {
Some(result)
}
}
fn source_path_in(dir: &Path, name: &str) -> Option<PathBuf> {
let candidates = [
dir.join(name),
dir.join(format!("{name}.sh")),
dir.join(format!("{name}.py")),
];
candidates.into_iter().find(|p| p.exists())
}
pub fn widget_source_path(name: &str) -> Option<PathBuf> {
source_path_in(&paths::widgets_dir(), name)
}
pub fn read_widget_source(name: &str) -> Option<String> {
let path = widget_source_path(name)?;
std::fs::read_to_string(path).ok()
}
pub fn write_widget_source(name: &str, content: &str) -> anyhow::Result<()> {
write_widget_source_in(&paths::widgets_dir(), name, content)
}
fn write_widget_source_in(dir: &Path, name: &str, content: &str) -> anyhow::Result<()> {
let path =
source_path_in(dir, name).ok_or_else(|| anyhow::anyhow!("widget not found: {name}"))?;
std::fs::write(&path, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&path, perms)?;
}
Ok(())
}
pub fn create_widget(name: &str, ext: &str, template: &str) -> anyhow::Result<()> {
create_widget_in(&paths::widgets_dir(), name, ext, template)
}
fn create_widget_in(dir: &Path, name: &str, ext: &str, template: &str) -> anyhow::Result<()> {
std::fs::create_dir_all(dir)?;
let filename = if ext.is_empty() {
name.to_string()
} else {
format!("{name}.{ext}")
};
let path = dir.join(&filename);
if path.exists() {
anyhow::bail!("widget already exists: {filename}");
}
std::fs::write(&path, template)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?;
}
let toml_path = dir.join(format!("{name}.toml"));
if !toml_path.exists() {
std::fs::write(
&toml_path,
format!("description = \"Custom widget: {name}\"\n"),
)?;
}
Ok(())
}
pub fn delete_widget(name: &str) -> anyhow::Result<()> {
delete_widget_in(&paths::widgets_dir(), name)
}
pub(crate) fn delete_widget_in(dir: &Path, name: &str) -> anyhow::Result<()> {
for candidate in [
dir.join(name),
dir.join(format!("{name}.sh")),
dir.join(format!("{name}.py")),
] {
if candidate.exists() {
std::fs::remove_file(&candidate)?;
}
}
let toml_path = dir.join(format!("{name}.toml"));
if toml_path.exists() {
std::fs::remove_file(&toml_path)?;
}
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct WidgetOutput {
pub text: String,
pub components: Vec<String>,
pub parts: HashMap<String, String>,
}
impl WidgetOutput {
pub fn compose(&self, requested: &[String], compact: bool) -> String {
if self.parts.is_empty() {
return self.text.clone();
}
let order: Vec<&str> = if requested.is_empty() {
self.components.iter().map(|s| s.as_str()).collect()
} else {
requested.iter().map(|s| s.as_str()).collect()
};
let sep = if compact { " " } else { " | " };
let composed: Vec<&str> = order
.iter()
.filter_map(|k| self.parts.get(*k).map(|s| s.as_str()))
.collect();
if composed.is_empty() {
self.text.clone()
} else {
composed.join(sep)
}
}
}
pub fn run_widget_full(name: &str, stdin_json: &str) -> Option<WidgetOutput> {
let widget_dir = paths::widgets_dir();
let candidates = [
widget_dir.join(name),
widget_dir.join(format!("{name}.sh")),
widget_dir.join(format!("{name}.py")),
];
let path = candidates.iter().find(|p| p.exists())?;
let canonical_widgets = widget_dir.canonicalize().ok()?;
let canonical_path = path.canonicalize().ok()?;
if !canonical_path.starts_with(&canonical_widgets) {
return None;
}
let mut child = Command::new(&canonical_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(stdin_json.as_bytes());
}
let child_id = child.id();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = child.wait_with_output();
let _ = tx.send(result);
});
let output = match rx.recv_timeout(std::time::Duration::from_millis(200)) {
Ok(Ok(out)) if out.status.success() => out,
_ => {
#[cfg(unix)]
unsafe {
libc::kill(child_id as i32, libc::SIGKILL);
}
return None;
}
};
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
return None;
}
let json_src = if stdout.contains('\x1b') {
stdout.replace('\x1b', "\\u001b")
} else {
stdout.clone()
};
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json_src) {
let text = v
.get("output")
.and_then(|o| o.as_str())
.unwrap_or("")
.to_string();
let components = v
.get("components")
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let parts = v
.get("parts")
.and_then(|p| p.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
Some(WidgetOutput {
text,
components,
parts,
})
} else {
Some(WidgetOutput {
text: stdout,
components: vec![],
parts: HashMap::new(),
})
}
}
pub fn rename_widget(old_name: &str, new_name: &str) -> anyhow::Result<()> {
let dir = paths::widgets_dir();
if new_name.is_empty() || new_name.contains(' ') || new_name.contains('/') {
anyhow::bail!("invalid widget name: {new_name}");
}
if let Some(old_path) = source_path_in(&dir, old_name) {
let ext = old_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let new_filename = if ext.is_empty() {
new_name.to_string()
} else {
format!("{new_name}.{ext}")
};
let new_path = dir.join(&new_filename);
if new_path.exists() {
anyhow::bail!("widget already exists: {new_filename}");
}
std::fs::rename(&old_path, &new_path)?;
} else {
anyhow::bail!("widget not found: {old_name}");
}
let old_toml = dir.join(format!("{old_name}.toml"));
let new_toml = dir.join(format!("{new_name}.toml"));
if old_toml.exists() {
std::fs::rename(&old_toml, &new_toml)?;
}
Ok(())
}
pub fn import_widget(source_path: &Path) -> anyhow::Result<String> {
let dir = paths::widgets_dir();
std::fs::create_dir_all(&dir)?;
let file_name = source_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!("invalid source path"))?;
let stem = source_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("invalid source path"))?;
let dest = dir.join(file_name);
if dest.exists() {
anyhow::bail!("widget already exists: {file_name}");
}
std::fs::copy(source_path, &dest)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
}
let toml_path = dir.join(format!("{stem}.toml"));
if !toml_path.exists() {
std::fs::write(&toml_path, format!("description = \"Imported: {stem}\"\n"))?;
}
Ok(stem.to_string())
}
pub fn mock_stdin_json() -> String {
serde_json::json!({
"data": {
"session_id": "test-preview",
"version": "1.2.16",
"model": {"display_name": "claude-sonnet-4-6"},
"context_window": {
"used_percentage": 42.0,
"context_window_size": 200000,
"current_usage": {"input_tokens": 84000}
},
"cost": {"total_duration_ms": 4830000, "total_cost_usd": 0.42},
"workspace": {"current_dir": "/home/user/project"},
"vim": {"mode": "NORMAL"},
"agent": {"name": "worker-1"},
"rate_limits": {
"five_hour": {"used_percentage": 60.0},
"seven_day": {"used_percentage": 75.0}
}
},
"config": {
"compact": false,
"components": [],
"settings": {}
}
})
.to_string()
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::{
create_widget_in, delete_widget_in, list_custom_widgets_in, list_widget_metas_in,
mock_stdin_json, source_path_in, write_widget_source_in, WidgetMeta,
};
fn make_file(dir: &TempDir, name: &str) {
fs::write(dir.path().join(name), "").unwrap();
}
fn write_widget_meta_in(
dir: &std::path::Path,
name: &str,
meta: &WidgetMeta,
) -> anyhow::Result<()> {
let toml_path = dir.join(format!("{name}.toml"));
let mut table = toml::Table::new();
table.insert(
"description".to_string(),
toml::Value::String(meta.description.clone()),
);
if !meta.components.is_empty() {
table.insert(
"components".to_string(),
toml::Value::try_from(meta.components.clone())?,
);
}
if meta.has_compact {
table.insert("has_compact".to_string(), toml::Value::Boolean(true));
}
std::fs::write(&toml_path, toml::to_string_pretty(&table)?)?;
Ok(())
}
#[test]
fn discovers_sh_py_and_bare_executables() {
let dir = TempDir::new().unwrap();
make_file(&dir, "mywidget.sh");
make_file(&dir, "another.py");
make_file(&dir, "bare_exec");
let mut widgets = list_custom_widgets_in(dir.path());
widgets.sort();
assert_eq!(widgets, vec!["another", "bare_exec", "mywidget"]);
}
#[test]
fn toml_sidecar_alone_is_not_a_widget() {
let dir = TempDir::new().unwrap();
make_file(&dir, "mywidget.toml");
let widgets = list_custom_widgets_in(dir.path());
assert!(widgets.is_empty());
}
#[test]
fn deduplicates_stem_with_and_without_extension() {
let dir = TempDir::new().unwrap();
make_file(&dir, "foo");
make_file(&dir, "foo.sh");
let widgets = list_custom_widgets_in(dir.path());
assert_eq!(widgets, vec!["foo"]);
}
#[test]
fn lists_all_widgets_including_core_names() {
let dir = TempDir::new().unwrap();
make_file(&dir, "git.sh");
make_file(&dir, "custom.sh");
let widgets = list_custom_widgets_in(dir.path());
assert_eq!(widgets, vec!["custom", "git"]);
}
#[test]
fn returns_empty_for_nonexistent_dir() {
let widgets =
list_custom_widgets_in(std::path::Path::new("/nonexistent/path/that/never/exists"));
assert!(widgets.is_empty());
}
#[test]
fn widget_without_sidecar_gets_defaults() {
let dir = TempDir::new().unwrap();
make_file(&dir, "simple.sh");
let metas = list_widget_metas_in(dir.path());
assert_eq!(metas.len(), 1);
let m = &metas[0];
assert_eq!(m.name, "simple");
assert_eq!(m.description, "Custom widget");
assert!(m.components.is_empty());
assert!(!m.has_compact);
}
#[test]
fn widget_with_sidecar_is_parsed() {
let dir = TempDir::new().unwrap();
make_file(&dir, "mywidget.sh");
fs::write(
dir.path().join("mywidget.toml"),
r#"description = "My widget"
components = ["a", "b"]
has_compact = true
"#,
)
.unwrap();
let metas = list_widget_metas_in(dir.path());
assert_eq!(metas.len(), 1);
let m = &metas[0];
assert_eq!(m.name, "mywidget");
assert_eq!(m.description, "My widget");
assert_eq!(m.components, vec!["a", "b"]);
assert!(m.has_compact);
}
#[test]
fn malformed_toml_falls_back_to_defaults() {
let dir = TempDir::new().unwrap();
make_file(&dir, "broken.sh");
fs::write(dir.path().join("broken.toml"), "not valid toml ][[[").unwrap();
let metas = list_widget_metas_in(dir.path());
assert_eq!(metas.len(), 1);
let m = &metas[0];
assert_eq!(m.description, "Custom widget");
assert!(m.components.is_empty());
assert!(!m.has_compact);
}
#[test]
fn source_path_prefers_exact_then_sh_then_py() {
let dir = TempDir::new().unwrap();
make_file(&dir, "mywidget.py");
assert!(source_path_in(dir.path(), "mywidget").is_some());
make_file(&dir, "mywidget.sh");
let p = source_path_in(dir.path(), "mywidget").unwrap();
assert_eq!(p.extension().and_then(|e| e.to_str()), Some("sh"));
}
#[test]
fn source_path_returns_none_for_missing_widget() {
let dir = TempDir::new().unwrap();
assert!(source_path_in(dir.path(), "ghost").is_none());
}
#[test]
fn write_and_read_widget_source() {
let dir = TempDir::new().unwrap();
make_file(&dir, "mywidget.sh");
write_widget_source_in(dir.path(), "mywidget", "#!/bin/sh\necho hi").unwrap();
let content = fs::read_to_string(dir.path().join("mywidget.sh")).unwrap();
assert_eq!(content, "#!/bin/sh\necho hi");
}
#[test]
fn write_widget_source_errors_on_missing_widget() {
let dir = TempDir::new().unwrap();
assert!(write_widget_source_in(dir.path(), "ghost", "content").is_err());
}
#[test]
fn create_widget_writes_script_and_sidecar() {
let dir = TempDir::new().unwrap();
create_widget_in(dir.path(), "mywidget", "sh", "#!/bin/sh\necho hello").unwrap();
assert!(dir.path().join("mywidget.sh").exists());
assert!(dir.path().join("mywidget.toml").exists());
let toml = fs::read_to_string(dir.path().join("mywidget.toml")).unwrap();
assert!(toml.contains("mywidget"));
}
#[test]
fn create_widget_rejects_duplicate() {
let dir = TempDir::new().unwrap();
create_widget_in(dir.path(), "dup", "sh", "").unwrap();
assert!(create_widget_in(dir.path(), "dup", "sh", "").is_err());
}
#[test]
fn delete_widget_removes_script_and_sidecar() {
let dir = TempDir::new().unwrap();
make_file(&dir, "gone.sh");
make_file(&dir, "gone.toml");
delete_widget_in(dir.path(), "gone").unwrap();
assert!(!dir.path().join("gone.sh").exists());
assert!(!dir.path().join("gone.toml").exists());
}
#[test]
fn delete_widget_noop_on_missing() {
let dir = TempDir::new().unwrap();
delete_widget_in(dir.path(), "ghost").unwrap();
}
#[test]
fn write_widget_meta_roundtrips() {
let dir = TempDir::new().unwrap();
let meta = WidgetMeta {
name: "mywidget".to_string(),
description: "does stuff".to_string(),
components: vec!["a".to_string(), "b".to_string()],
has_compact: true,
};
write_widget_meta_in(dir.path(), "mywidget", &meta).unwrap();
let metas = list_widget_metas_in(dir.path());
let raw = fs::read_to_string(dir.path().join("mywidget.toml")).unwrap();
let table: toml::Table = raw.parse().unwrap();
assert_eq!(table["description"].as_str().unwrap(), "does stuff");
assert!(table["has_compact"].as_bool().unwrap());
assert!(metas.is_empty());
}
#[test]
fn mock_stdin_json_is_valid_json() {
let s = mock_stdin_json();
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["data"]["session_id"], "test-preview");
assert!(v["config"]["components"].is_array());
}
}