Add Live Activities as configurable bar items (#30)

* Add Live Activities as configurable bar items

* Fix activity owner monitoring and Morph list interactions

* Keep activity rows stable during updates and tighten their layout

* Use subtle theme tint for activity cards

* Tighten activity title and progress spacing

* Use theme accent for active progress rings

* Fit compact activities to measured titles and badge

* Expose Live Activities display controls in Settings

* Open activity terminals and streamline the Morph header

* Give Morph continuous icons and prioritize workspace navigation

* Show activity progress inside its ring

* Inset activity progress rings and strengthen their stroke

* Fix activity lifecycle, command output handling and list navigation

* Reduce activity update work and harden publisher lifetimes

* Set up Live Activities for the detected user shell during installation

* Document Activities setup, daily use and troubleshooting

* Preserve Morph idle width across empty activity updates

* Fix dimming flicker, hide inactive music tab and consolidate Activities docs

* Fix panel clearance across styles and match previews to theme settings

* Prevent panel blur from repainting neighboring islands

---------

Co-authored-by: Samuel Onoja <[email protected]>
This commit is contained in:
Samuel OnojaandSamuel Onoja authored and GitHub committed 2026-10-12 22:52:25 +01:00
1 parent 7a7df0106c
commit 6b0d903ad0
107 files changed
+9340 -291

No files matched your search

+401
View File
@@ -0,0 +1,401 @@
//! Activity rules are matched in declaration order, followed by unreplaced
//! built-ins. Matching metadata is bounded and never stored in the result.
use kdl::KdlNode;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
/// Enable installation defaults without overriding an explicit user opt-out.
pub fn setup_shell_hook(document: &mut crate::Document) -> Result<bool, crate::Error> {
let explicit = document
.doc
.get("activities")
.and_then(kdl::KdlNode::children)
.and_then(|sources| sources.get("sources"))
.and_then(kdl::KdlNode::children)
.and_then(|sources| sources.get("shell-hook"))
.and_then(|node| node.get(0))
.and_then(kdl::KdlValue::as_bool);
if explicit == Some(false) {
return Ok(false);
}
if explicit.is_none() {
document.set("activities.sources.shell_hook", json!(true))?;
}
Ok(true)
}
use crate::Error;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub max_visible: usize,
pub show_badge: bool,
pub max_total: usize,
pub owner_ceiling: u8,
pub start_delay_ms: u64,
pub result_flash_ms: u64,
pub sources: Sources,
pub rules: Vec<Rule>,
}
impl Default for Settings {
fn default() -> Self {
Self {
max_visible: 2,
show_badge: true,
max_total: 128,
owner_ceiling: 160,
start_delay_ms: 300,
result_flash_ms: 1_500,
sources: Sources::default(),
rules: builtins(),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Sources {
pub shell_hook: bool,
pub process_watch: bool,
pub notification_bridge: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Rule {
pub name: String,
#[serde(rename = "match")]
pub pattern: String,
pub title: String,
pub icon: String,
pub priority: u8,
pub progress: String,
pub min_duration_ms: u64,
pub ignore_parents: Vec<String>,
pub dedupe_by: Option<String>,
pub flash_on_fail: bool,
pub result: ResultText,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ResultText {
pub ok: String,
pub fail: String,
}
#[derive(Clone, Debug)]
pub struct Matcher {
rules: Vec<(Rule, Regex)>,
}
#[derive(Clone, Debug)]
pub struct Matched {
pub rule: Rule,
pub title: String,
pub group: Option<String>,
}
impl Matcher {
pub fn new(settings: &Settings) -> Result<Self, String> {
let mut rules = Vec::new();
for rule in &settings.rules {
let regex = regex::RegexBuilder::new(&rule.pattern)
.size_limit(256 * 1024)
.dfa_size_limit(256 * 1024)
.build()
.map_err(|error| error.to_string())?;
validate_rule(rule, &regex)?;
rules.push((rule.clone(), regex));
}
Ok(Self { rules })
}
pub fn matches(&self, command: &str, cwd: &str, parents: &[String]) -> Option<Matched> {
if command.len() > 4_096 || cwd.len() > 1_024 || parents.len() > 16 {
return None;
}
for (rule, regex) in &self.rules {
if parents.iter().any(|parent| rule.ignore_parents.contains(parent)) {
continue;
}
if let Some(captures) = regex.captures(command) {
let mut title = rule.title.clone();
for index in 0..captures.len() {
title = title.replace(&format!("{{{index}}}"), captures.get(index).map_or("", |m| m.as_str()));
}
if title.len() > 256 || title.chars().any(char::is_control) {
return None;
}
return Some(Matched {
rule: rule.clone(),
title,
group: (rule.dedupe_by.as_deref() == Some("cwd")).then(|| {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
cwd.hash(&mut hash);
format!("{}:{:x}", rule.name, hash.finish())
}),
});
}
}
None
}
}
fn validate_rule(rule: &Rule, regex: &Regex) -> Result<(), String> {
for value in [&rule.name, &rule.title, &rule.result.ok, &rule.result.fail] {
if value.len() > 256 || value.chars().any(char::is_control) {
return Err("rule text exceeds its budget".into());
}
}
if rule.pattern.len() > 2_048
|| rule.min_duration_ms > 60_000
|| !matches!(rule.progress.as_str(), "indeterminate" | "cargo-json")
|| rule.dedupe_by.as_deref().is_some_and(|value| value != "cwd")
{
return Err("invalid rule duration, progress adapter or dedupe policy".into());
}
if rule.ignore_parents.len() > 16 || rule.ignore_parents.iter().any(|name| name.len() > 128) {
return Err("too many or too long parent names".into());
}
if rule.icon.len() > 64 || !rule.icon.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err("invalid symbolic icon".into());
}
let capture = Regex::new(r"\{([^}]+)\}").expect("static pattern");
for reference in capture.captures_iter(&rule.title) {
if reference[1]
.parse::<usize>()
.ok()
.is_none_or(|index| index >= regex.captures_len())
{
return Err("invalid title capture reference".into());
}
}
Ok(())
}
impl Settings {
pub fn limits(&self) -> ferese_activities::Limits {
ferese_activities::Limits {
max_visible: self.max_visible,
show_badge: self.show_badge,
max_total: self.max_total,
owner_ceiling: self.owner_ceiling,
start_delay_ms: self.start_delay_ms,
result_flash_ms: self.result_flash_ms,
}
}
pub fn validate(&self) -> Result<(), String> {
if !(1..=2).contains(&self.max_visible)
|| !(1..=128).contains(&self.max_total)
|| self.start_delay_ms > 60_000
|| self.result_flash_ms > 10_000
|| self.rules.len() > 64
{
return Err("activity settings exceed supported limits".into());
}
let mut names = std::collections::HashSet::new();
if self.rules.iter().any(|rule| !names.insert(&rule.name)) {
return Err("duplicate activity rule".into());
}
Matcher::new(self).map(|_| ())
}
}
fn diagnostic(node: &KdlNode, message: impl std::fmt::Display) -> Error {
Error(format!("activities at byte {}: {message}", node.span().offset()))
}
pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
if !node.entries().is_empty() {
return Err(diagnostic(node, "section takes no properties"));
}
let mut values = Map::new();
let mut rules = Vec::new();
let mut names = std::collections::HashSet::new();
for child in node.children().into_iter().flat_map(|doc| doc.nodes()) {
let key = child.name().value();
if key == "rule" {
let Some(name) = child.get(0).and_then(kdl::KdlValue::as_string) else {
return Err(diagnostic(child, "rule requires a name"));
};
if child.entries().len() != 1 || !names.insert(name.to_owned()) {
return Err(diagnostic(child, "duplicate rule or extra rule properties"));
}
let mut value = Map::new();
value.insert("name".into(), json!(name));
for property in child.children().into_iter().flat_map(|doc| doc.nodes()) {
let name = property.name().value();
if !matches!(
name,
"match"
| "title"
| "icon"
| "priority"
| "progress"
| "min-duration-ms"
| "ignore-parents"
| "dedupe-by"
| "flash-on-fail"
| "result"
) {
return Err(diagnostic(property, "unknown rule node"));
}
let key = name.replace('-', "_");
let parsed = if name == "ignore-parents" {
if property.entries().iter().any(|entry| entry.name().is_some()) || property.entries().is_empty() {
return Err(diagnostic(property, "ignore-parents requires names"));
}
Value::Array(
property
.entries()
.iter()
.map(|entry| crate::scalar(entry.value()))
.collect::<Result<_, _>>()?,
)
} else {
crate::node_value(property, &key, "rule").map_err(|error| diagnostic(property, error))?
};
if value.insert(key, parsed).is_some() {
return Err(diagnostic(property, "duplicate rule setting"));
}
}
value.entry("icon").or_insert(json!("ferese"));
value.entry("priority").or_insert(json!(100));
value.entry("progress").or_insert(json!("indeterminate"));
value.entry("min_duration_ms").or_insert(json!(0));
value.entry("ignore_parents").or_insert(json!([]));
value.entry("dedupe_by").or_insert(Value::Null);
value.entry("flash_on_fail").or_insert(json!(true));
value
.entry("result")
.or_insert(json!({"ok":"Finished","fail":"Command failed"}));
let rule: Rule = serde_json::from_value(Value::Object(value)).map_err(|error| diagnostic(child, error))?;
let settings = Settings {
rules: vec![rule.clone()],
..Default::default()
};
settings.validate().map_err(|error| diagnostic(child, error))?;
rules.push(rule);
} else {
if !matches!(
key,
"max-visible"
| "show-badge"
| "max-total"
| "owner-ceiling"
| "start-delay-ms"
| "result-flash-ms"
| "sources"
) {
return Err(diagnostic(child, "unknown activity node"));
}
let field = key.replace('-', "_");
let value = crate::node_value(child, &field, "activities").map_err(|error| diagnostic(child, error))?;
if values.insert(field, value).is_some() {
return Err(diagnostic(child, "duplicate activity setting"));
}
}
}
rules.extend(builtins().into_iter().filter(|rule| !names.contains(&rule.name)));
values.insert("rules".into(), serde_json::to_value(rules).unwrap());
let settings: Settings = serde_json::from_value(Value::Object(values)).map_err(|error| diagnostic(node, error))?;
settings.validate().map_err(|error| diagnostic(node, error))?;
Ok(serde_json::to_value(settings).unwrap())
}
fn builtins() -> Vec<Rule> {
[
(
"cargo",
r"^cargo (build|check|test|run|clippy)\b",
"cargo {1}",
"Finished",
1_500,
),
(
"npm",
r"^(npm|pnpm) (install|build|test|run)\b",
"{1} {2}",
"Finished",
1_500,
),
("make", r"^make(?:\s|$)", "Make", "Built", 300),
("git-push", r"^git push(?:\s|$)", "Git push", "Pushed", 300),
(
"docker-build",
r"^docker build(?:\s|$)",
"Docker build",
"Image built",
1_500,
),
]
.into_iter()
.map(|(name, pattern, title, ok, min_duration_ms)| Rule {
name: name.into(),
pattern: pattern.into(),
title: title.into(),
icon: "hammer".into(),
priority: 100,
progress: "indeterminate".into(),
min_duration_ms,
ignore_parents: if name == "cargo" {
vec!["rust-analyzer".into()]
} else {
vec![]
},
dedupe_by: Some("cwd".into()),
flash_on_fail: true,
result: ResultText {
ok: ok.into(),
fail: "Command failed".into(),
},
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_are_opt_in_and_matching_discards_nonmatches() {
let settings = Settings::default();
assert!(!settings.sources.shell_hook);
assert!(!settings.sources.process_watch);
let matcher = Matcher::new(&settings).unwrap();
assert!(matcher.matches("secret command", "/tmp", &[]).is_none());
let matched = matcher.matches("cargo test", "/tmp", &[]).unwrap();
assert_eq!(matched.title, "cargo test");
assert!(
matcher
.matches("cargo test", "/tmp", &["rust-analyzer".into()])
.is_none()
);
}
#[test]
fn shipped_kdl_matches_the_builtin_catalog() {
let document = crate::Document::parse(include_str!("../activities.kdl")).unwrap();
assert_eq!(
document.value()["activities"]["rules"],
serde_json::to_value(Settings::default().rules).unwrap()
);
}
#[test]
fn complete_rule_replacement_and_diagnostics() {
let source = "activities { rule cargo { match \"^cargo\"; title Custom; }; }";
let doc = crate::Document::parse(source).unwrap();
let settings: Settings = serde_json::from_value(doc.value()["activities"].clone()).unwrap();
assert_eq!(settings.rules[0].title, "Custom");
assert_eq!(settings.rules[0].min_duration_ms, 0);
assert_eq!(settings.rules.len(), 5);
for invalid in [
"activities { unknown 1; }",
"activities { max-visible 3; }",
"activities { show-badge 1; }",
"activities { rule x { match \"[\"; title X; }; }",
"activities { rule x { match \"(a)\"; title \"{2}\"; }; }",
"activities { sources { typo #true; }; }",
] {
assert!(
crate::Document::parse(invalid)
.unwrap_err()
.to_string()
.contains("byte")
);
}
}
}
+6 -1
View File
@@ -1,3 +1,4 @@
pub mod activities;
pub mod families;
pub mod file;
pub mod notifications;
@@ -144,7 +145,11 @@ fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error>
continue;
}
let value = node_value(node, &key, parent)?;
let value = if parent.is_empty() && key == "activities" {
activities::parse(node)?
} else {
node_value(node, &key, parent)?
};
if is_records(&key, parent) {
let records = result.entry(key).or_insert_with(|| Value::Array(Vec::new()));
records.as_array_mut().unwrap().push(value);