Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "understatus"
version = "0.7.2"
version = "0.7.3"
edition = "2021"
description = "A calm, unobtrusive macOS statusline addon for AI coding CLIs (Claude Code): CPU/memory/disk/network + session info with a quiet glyph theme."
# macOS 전용 크레이트 (host_processor_info / sysctl / IOKit FFI 의존)
Expand Down
2 changes: 1 addition & 1 deletion npm/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const os = require('os');
// 네이티브 바이너리를 받아오는 GitHub 릴리스 태그.
// release/publish guard가 Cargo.toml, npm/package.json, 이 값, git tag를 lockstep으로
// 검증합니다. 새 네이티브 릴리스를 낼 때 네 버전을 함께 올리세요.
const VERSION = '0.7.2';
const VERSION = '0.7.3';

// CPU 아키텍처를 Rust 타겟 트리플로 매핑
const archMap = {
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "understatus",
"version": "0.7.2",
"version": "0.7.3",
"description": "Claude Code statusline addon for macOS — CPU, memory, session info with a calm glyph theme",
"bin": {
"understatus": "bin/understatus.js"
Expand Down
223 changes: 213 additions & 10 deletions src/cmux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ use crate::render::{CmuxStatusOutput, Pill};

/// Session cache file containing the last keys applied by the Codex/CMUX bridge.
const APPLIED_KEYS_CACHE: &str = "cmux_codex_applied_keys";
const STATUS_KEY_PREFIX: &str = "understatus.codex.";
const HOOK_SIDEBAR_STATE_TIMEOUT_MS: u64 = 750;

/// Default hard timeout for a single manual `cmux` CLI invocation.
pub(crate) const DEFAULT_CMUX_TIMEOUT_MS: u64 = 5_000;
Expand Down Expand Up @@ -128,7 +130,8 @@ fn env_nonempty(name: &str) -> Option<String> {
/// Derive a stable namespaced CMUX key for one understatus pill.
pub(crate) fn namespaced_key(owner: &str, pill_key: &str) -> String {
format!(
"understatus.codex.{}.{}",
"{}{}.{}",
STATUS_KEY_PREFIX,
chain::sanitize_session_key(owner),
chain::sanitize_session_key(pill_key)
)
Expand Down Expand Up @@ -227,20 +230,33 @@ pub(crate) fn apply_pills(
};
}

let apply_started = Instant::now();
let previous = read_applied_state(&owner);
let observed_keys = read_target_status_keys(&target, sidebar_state_timeout_ms(mode));
let execute_mode = match mode_after_elapsed(mode, apply_started.elapsed()) {
Some(mode) => mode,
None => {
return ApplyReport {
target_resolved: true,
set_count: 0,
clear_count: 0,
command_success: false,
};
}
};
let current_keys: Vec<String> = output
.pills
.iter()
.map(|pill| namespaced_key(&owner, &pill.key))
.collect();
let (clear_commands, set_commands) =
plan_apply_commands(output, &owner, &target, previous.as_ref());
plan_apply_commands(output, &owner, &target, previous.as_ref(), &observed_keys);
let clear_count = clear_commands.len();
let set_count = set_commands.len();
let mut all_commands = clear_commands;
all_commands.extend(set_commands);

let command_success = execute_commands(&all_commands, mode);
let command_success = execute_commands(&all_commands, execute_mode);
if command_success {
write_applied_state(
&owner,
Expand Down Expand Up @@ -284,10 +300,7 @@ pub(crate) fn clear_owner(
};
}
let keys = previous.map(|state| state.keys).unwrap_or_default();
let commands: Vec<CmuxCommand> = keys
.iter()
.map(|key| plan_clear_status(key, &target))
.collect();
let commands = plan_clear_commands(&keys, &target, &[]);
let clear_count = commands.len();
let command_success = execute_commands(&commands, mode);
if command_success {
Expand All @@ -307,18 +320,55 @@ pub(crate) fn clear_owner(
}
}

/// Clear every understatus Codex key visible on the target. This is the fallback cleanup path for
/// legacy per-session owners and for Codex clients that do not emit a session-end hook.
pub(crate) fn clear_all_codex(target: CmuxTarget, mode: ApplyMode) -> ApplyReport {
if mode.hook && !target.has_caller_target() {
return ApplyReport {
target_resolved: false,
set_count: 0,
clear_count: 0,
command_success: true,
};
}
let clear_started = Instant::now();
let observed_keys = read_target_status_keys(&target, sidebar_state_timeout_ms(mode));
let execute_mode = match mode_after_elapsed(mode, clear_started.elapsed()) {
Some(mode) => mode,
None => {
return ApplyReport {
target_resolved: true,
set_count: 0,
clear_count: 0,
command_success: false,
};
}
};
let commands = plan_clear_commands(&[], &target, &observed_keys);
let clear_count = commands.len();
let command_success = execute_commands(&commands, execute_mode);
ApplyReport {
target_resolved: true,
set_count: 0,
clear_count,
command_success,
}
}

fn plan_apply_commands(
output: &CmuxStatusOutput,
owner: &str,
target: &CmuxTarget,
previous: Option<&AppliedState>,
observed_keys: &[String],
) -> (Vec<CmuxCommand>, Vec<CmuxCommand>) {
let current: BTreeSet<String> = output
.pills
.iter()
.map(|pill| namespaced_key(owner, &pill.key))
.collect();
let mut clear = Vec::new();
let mut seen_clear = BTreeSet::new();
if let Some(previous) = previous {
let previous_keys: BTreeSet<String> = previous.keys.iter().cloned().collect();
let stale: Vec<String> = if previous.target == *target {
Expand All @@ -327,9 +377,12 @@ fn plan_apply_commands(
previous_keys.into_iter().collect()
};
for key in stale {
clear.push(plan_clear_status(&key, &previous.target));
push_clear_unique(&mut clear, &mut seen_clear, &key, &previous.target);
}
}
for key in stale_observed_keys(observed_keys, &current) {
push_clear_unique(&mut clear, &mut seen_clear, &key, target);
}
let set = output
.pills
.iter()
Expand All @@ -338,6 +391,97 @@ fn plan_apply_commands(
(clear, set)
}

fn plan_clear_commands(
owner_keys: &[String],
target: &CmuxTarget,
observed_keys: &[String],
) -> Vec<CmuxCommand> {
let mut clear = Vec::new();
let mut seen_clear = BTreeSet::new();
for key in owner_keys
.iter()
.filter(|key| key.starts_with(STATUS_KEY_PREFIX))
{
push_clear_unique(&mut clear, &mut seen_clear, key, target);
}
for key in stale_observed_keys(observed_keys, &BTreeSet::new()) {
push_clear_unique(&mut clear, &mut seen_clear, &key, target);
}
clear
}

fn stale_observed_keys(observed_keys: &[String], keep: &BTreeSet<String>) -> Vec<String> {
observed_keys
.iter()
.filter(|key| key.starts_with(STATUS_KEY_PREFIX) && !keep.contains(*key))
.cloned()
.collect()
}

fn push_clear_unique(
clear: &mut Vec<CmuxCommand>,
seen: &mut BTreeSet<String>,
key: &str,
target: &CmuxTarget,
) {
let command = plan_clear_status(key, target);
let signature = command.args.join("\u{1f}");
if seen.insert(signature) {
clear.push(command);
}
}

fn read_target_status_keys(target: &CmuxTarget, timeout_ms: u64) -> Vec<String> {
let mut args = vec!["sidebar-state".to_string()];
target.append_target_args(&mut args);
let mut process = Command::new("cmux");
process
.args(args)
.stdin(Stdio::null())
.stderr(Stdio::null())
.stdout(Stdio::piped());
let Some(output) = command_output_with_timeout(&mut process, timeout_ms) else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let text = String::from_utf8_lossy(&output.stdout);
parse_sidebar_status_keys(&text)
}

fn sidebar_state_timeout_ms(mode: ApplyMode) -> u64 {
if mode.hook {
HOOK_SIDEBAR_STATE_TIMEOUT_MS.min(mode.timeout_ms)
} else {
mode.timeout_ms
}
}

fn mode_after_elapsed(mut mode: ApplyMode, elapsed: Duration) -> Option<ApplyMode> {
let Some(total_timeout_ms) = mode.total_timeout_ms else {
return Some(mode);
};
let elapsed_ms = elapsed.as_millis().try_into().unwrap_or(u64::MAX);
let remaining_ms = total_timeout_ms.checked_sub(elapsed_ms)?;
if remaining_ms == 0 {
return None;
}
mode.total_timeout_ms = Some(remaining_ms);
Some(mode)
}

fn parse_sidebar_status_keys(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let line = line.trim_start();
let (key, _) = line.split_once('=')?;
let key = key.trim();
key.starts_with(STATUS_KEY_PREFIX).then(|| key.to_string())
})
.collect()
}

fn plan_set_status(owner: &str, pill: &Pill, target: &CmuxTarget) -> CmuxCommand {
let mut args = vec![
"set-status".to_string(),
Expand Down Expand Up @@ -472,8 +616,13 @@ mod tests {
workspace: Some("workspace:1".to_string()),
window: Some("window:2".to_string()),
};
let (_, set) =
plan_apply_commands(&output(&[("model", "gpt-5.5")]), "owner", &target, None);
let (_, set) = plan_apply_commands(
&output(&[("model", "gpt-5.5")]),
"owner",
&target,
None,
&[],
);
assert_eq!(set.len(), 1);
assert_eq!(set[0].args[0], "set-status");
assert!(set[0].args.contains(&"--workspace".to_string()));
Expand Down Expand Up @@ -516,13 +665,67 @@ mod tests {
"owner",
&target,
Some(&previous),
&[],
);
assert_eq!(set.len(), 1);
assert_eq!(clear.len(), 1);
assert_eq!(clear[0].args[0], "clear-status");
assert!(clear[0].args[1].ends_with(".ctx"));
}

#[test]
fn plan_apply_clears_observed_legacy_session_keys() {
let target = CmuxTarget {
workspace: Some("workspace:1".to_string()),
window: None,
};
let observed = vec![
namespaced_key("old-session", "cpu"),
namespaced_key("old-session", "mem"),
namespaced_key("owner", "cpu"),
"other.tool.cpu".to_string(),
];
let (clear, set) = plan_apply_commands(
&output(&[("cpu", "cpu 10%")]),
"owner",
&target,
None,
&observed,
);
assert_eq!(set.len(), 1);
let clear_keys: Vec<String> = clear.iter().map(|cmd| cmd.args[1].clone()).collect();
assert_eq!(
clear_keys,
vec![
namespaced_key("old-session", "cpu"),
namespaced_key("old-session", "mem")
]
);
assert!(clear.iter().all(|cmd| cmd.args[0] == "clear-status"));
assert!(clear
.iter()
.all(|cmd| cmd.args.contains(&"--workspace".to_string())));
}

#[test]
fn parse_sidebar_status_keys_keeps_only_understatus_keys() {
let keys = parse_sidebar_status_keys(
r#"
status_count=3
understatus.codex.old.cpu=cpu 31% color=#6D8296 priority=100
build=ok color=#00ff00 priority=1
understatus.codex.old.mem=mem 68% color=#A0A0A0 priority=90
"#,
);
assert_eq!(
keys,
vec![
"understatus.codex.old.cpu".to_string(),
"understatus.codex.old.mem".to_string()
]
);
}

#[test]
fn hook_mode_without_target_noops() {
let report = apply_pills(
Expand Down
Loading
Loading