Add full disk access checks and settings

Add background themes and custom images

Add shell command logging toggle

Add UTF-8 locale guarantee for PTY

Add Claude hook settings injection

Add hotkey system for GUI

Add glass panel styling

Add search disabled state for agent panels

Add zoom toggle command

Add device report filtering

Add entitlements for notarization

Update version to 0.1.27
This commit is contained in:
2026-06-15 22:26:06 +07:00
parent 2ee2aaaffb
commit ee845e15b3
30 changed files with 859 additions and 123 deletions
+18
View File
@@ -20,6 +20,12 @@ pub struct AppearanceConfig {
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<String>,
/// Background-theme name (Warp-style full-window fill). "none" = solid.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
/// Absolute path to a custom background image (used when background == "custom").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_image: Option<String>,
}
/// Built-in resume args for known agents, used when config has no override.
@@ -51,6 +57,10 @@ pub struct Config {
/// How often (seconds) the daemon dumps changed grids to disk.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_interval_secs: Option<u64>,
/// Log/notify shell-command status (OSC 133 / fallback) in plain panels.
/// Off by default — only agent activity (claude/codex/… hooks) is logged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_shell_commands: Option<bool>,
}
impl Config {
@@ -62,9 +72,17 @@ impl Config {
font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()),
background: self.appearance.background.clone().unwrap_or_else(|| "none".into()),
background_image: self.appearance.background_image.clone().unwrap_or_default(),
log_shell_commands: self.log_shell_commands(),
}
}
/// Whether shell-command status events are logged (default false).
pub fn log_shell_commands(&self) -> bool {
self.log_shell_commands.unwrap_or(false)
}
/// Shell for a plain panel using THIS in-memory config
/// (env -> config -> passwd -> $SHELL -> /bin/sh).
pub fn resolved_shell(&self) -> String {
+38 -33
View File
@@ -18,35 +18,39 @@ fn dir_for(home: &PathBuf, sid: &SurfaceId) -> PathBuf {
home.join(".spacesh").join("hooks").join(&sid.0)
}
/// Build the settings.json contents wiring Stop/Notification/UserPromptSubmit
/// to `spacesh notify`. `spacesh_bin` is the absolute path to the CLI.
/// Our Stop/Notification/UserPromptSubmit hooks as a JSON value (the `hooks` object).
fn our_hooks(spacesh_bin: &str) -> serde_json::Value {
let entry = |state: &str| serde_json::json!({
"hooks": [{
"type": "command",
"command": format!("{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}")
}]
});
serde_json::json!({
"Stop": [entry("done")],
"Notification": [entry("wait")],
"UserPromptSubmit": [entry("work")],
})
}
/// Our hooks as a standalone settings JSON string. Passed to `claude` via the
/// `--settings` flag so they layer ON TOP of the user's real config WITHOUT
/// relocating CLAUDE_CONFIG_DIR — which is the whole point: claude only reads the
/// macOS Keychain login (and onboarding state) for its DEFAULT config dir, so any
/// override left the agent "Not logged in". `--settings` keeps the default dir.
pub fn settings_json(spacesh_bin: &str) -> String {
let line = |state: &str| {
format!(
"{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}\"}}]}}"
)
};
format!(
"{{\"hooks\":{{\"Stop\":[{}],\"Notification\":[{}],\"UserPromptSubmit\":[{}]}}}}",
line("done"), line("wait"), line("work")
)
serde_json::to_string(&serde_json::json!({ "hooks": our_hooks(spacesh_bin) }))
.unwrap_or_else(|_| "{\"hooks\":{}}".to_string())
}
/// Prepare the per-surface hook config; return env pairs to merge into the spawn.
/// Best-effort: on any I/O error returns an empty vec (spawn proceeds without hooks).
pub fn prepare(sid: &SurfaceId, spacesh_bin: &str) -> Vec<(String, String)> {
let Some(home) = dirs::home_dir() else { return vec![] };
let dir = dir_for(&home, sid);
if std::fs::create_dir_all(&dir).is_err() {
return vec![];
}
if std::fs::write(dir.join("settings.json"), settings_json(spacesh_bin)).is_err() {
return vec![];
}
vec![("CLAUDE_CONFIG_DIR".to_string(), dir.to_string_lossy().to_string())]
/// Extra CLI args injecting our notify hooks into a spawned `claude`, leaving its
/// default config dir (Keychain auth + onboarding) untouched.
pub fn claude_settings_args(spacesh_bin: &str) -> Vec<String> {
vec!["--settings".to_string(), settings_json(spacesh_bin)]
}
/// Remove the per-surface hook dir (best-effort) on close.
/// Remove the legacy per-surface hook dir (best-effort) on close. No longer
/// written, but cleans up dirs left by older builds that used CLAUDE_CONFIG_DIR.
pub fn cleanup(sid: &SurfaceId) {
if let Some(home) = dirs::home_dir() {
let _ = std::fs::remove_dir_all(dir_for(&home, sid));
@@ -129,15 +133,16 @@ mod tests {
}
#[test]
fn prepare_writes_config_and_cleanup_removes_it() {
let sid = SurfaceId(format!("s_test_{}", std::process::id()));
let env = prepare(&sid, "/abs/spacesh");
assert_eq!(env.len(), 1);
assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR");
let dir = std::path::PathBuf::from(&env[0].1);
assert!(dir.join("settings.json").exists());
cleanup(&sid);
assert!(!dir.exists());
fn claude_settings_args_pass_hooks_via_settings_flag() {
let args = claude_settings_args("/abs/spacesh");
assert_eq!(args.len(), 2);
assert_eq!(args[0], "--settings");
// The second arg is valid JSON carrying all three hook events.
let v: serde_json::Value = serde_json::from_str(&args[1]).unwrap();
assert!(v["hooks"]["Stop"].is_array());
assert!(args[1].contains("/abs/spacesh notify --surface $SPACESH_SURFACE_ID --state done"));
assert!(args[1].contains("--state wait"));
assert!(args[1].contains("--state work"));
}
#[test]
+34 -16
View File
@@ -189,8 +189,13 @@ async fn router(
if reg.is_running(&surface_id) {
reg.set_state(&surface_id, state);
broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state }));
if let Some(kind) = kind_for_state(state) {
record_event(&reg, &mut event_log, &event_persister, &clients, &surface_id, kind);
// StateDetected is the shell path (OSC 133 / fallback scanner). Off by
// default it stays a live status ring only — no log entry, no notification.
// Agent activity flows through Cmd::SetState (hooks) and is always logged.
if config.log_shell_commands() {
if let Some(kind) = kind_for_state(state) {
record_event(&reg, &mut event_log, &event_persister, &clients, &surface_id, kind);
}
}
}
}
@@ -275,9 +280,10 @@ fn err(id: u64, code: &str, msg: &str) -> Envelope {
/// and whether a deterministic hook source is active.
fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (Vec<(String, String)>, bool) {
let (mut env, active) = if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
let env = crate::hooks::prepare(sid, &crate::hooks::spacesh_bin());
let active = !env.is_empty();
(env, active)
// Hooks are injected as `--settings` CLI args at spawn (see spawn_from_spec),
// not via env — that keeps claude on its default config dir so Keychain login
// and onboarding survive. The agent still has a deterministic hook source.
(vec![], true)
} else if crate::hooks::is_zsh(&spec.command) {
(crate::hooks::shell_env(sid), false)
} else {
@@ -756,7 +762,7 @@ async fn handle_request(
}
}
Cmd::SetConfig { default_shell, font_family, font_size, theme, accent } => {
Cmd::SetConfig { default_shell, font_family, font_size, theme, accent, background, background_image, log_shell_commands } => {
if let Some(v) = &theme {
if v != "dark" && v != "light" { let _ = out.send(err(id, "BAD_CONFIG", "theme")).await; return; }
}
@@ -764,12 +770,19 @@ async fn handle_request(
const ACCENTS: [&str; 5] = ["blue", "teal", "purple", "green", "orange"];
if !ACCENTS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "accent")).await; return; }
}
if let Some(v) = &background {
const BACKGROUNDS: [&str; 8] = ["none", "cyberwave", "phenomenon", "dracula", "aurora", "ember", "referred", "custom"];
if !BACKGROUNDS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "background")).await; return; }
}
let mut next = config.clone();
if let Some(v) = default_shell { next.default_shell = if v.is_empty() { None } else { Some(v) }; }
if let Some(v) = font_family { next.terminal.font_family = if v.is_empty() { None } else { Some(v) }; }
if let Some(v) = font_size { next.terminal.font_size = Some(v.clamp(10, 20)); }
if let Some(v) = theme { next.appearance.theme = Some(v); }
if let Some(v) = accent { next.appearance.accent = Some(v); }
if let Some(v) = background { next.appearance.background = if v == "none" { None } else { Some(v) }; }
if let Some(v) = background_image { next.appearance.background_image = if v.is_empty() { None } else { Some(v) }; }
if let Some(v) = log_shell_commands { next.log_shell_commands = Some(v); }
let to_save = next.clone();
match tokio::task::spawn_blocking(move || to_save.save()).await {
Ok(Ok(())) => {
@@ -1244,8 +1257,7 @@ mod tests {
}).await;
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
// Observer connection: receives all broadcast events (the detected-state path
// flows through ServerMsg::StateDetected → record_event → Evt::Event).
// Observer connection: receives all broadcast events.
let mut observer = UnixStream::connect(&sock).await.unwrap();
// Drive the PTY output by attaching the control connection.
@@ -1253,21 +1265,27 @@ mod tests {
surface_id: spacesh_proto::SurfaceId(sid.clone()),
}).await;
// Expect an Evt::Event (kind=done) for this surface from the OSC 133 Done detection.
let mut found = None;
// Shell-command status (OSC 133) updates the live status ring (Evt::State) but is
// NOT logged by default — log_shell_commands defaults to false, so no Evt::Event
// is recorded for plain shell panels. (Agent activity flows through Cmd::SetState.)
let mut saw_state_done = false;
let mut saw_event = false;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(Some(env))) =
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await {
if let Envelope::Evt(Evt::Event { record }) = env {
if record.surface_id.0 == sid { found = Some(record); break; }
match env {
Envelope::Evt(Evt::State { surface_id, state })
if surface_id.0 == sid && state == spacesh_proto::status::SurfaceState::Done => { saw_state_done = true; }
// Exit (process end) is always logged; only command-status events are gated.
Envelope::Evt(Evt::Event { record })
if record.surface_id.0 == sid && record.kind != spacesh_proto::event::EventKind::Exit => { saw_event = true; }
_ => {}
}
}
}
let rec = found.expect("expected an Evt::Event from the OSC 133 detected state");
assert_eq!(rec.kind, spacesh_proto::event::EventKind::Done);
assert!(!rec.read);
assert_eq!(rec.workspace_id.0, ws);
assert!(saw_state_done, "expected an Evt::State(done) from the OSC 133 detection");
assert!(!saw_event, "shell-command events must not be logged when log_shell_commands is off");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+8 -1
View File
@@ -29,9 +29,16 @@ pub fn spawn_from_spec(
) -> std::io::Result<SurfaceHandle> {
let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())];
env.extend(extra_env);
// For a Claude agent, inject our notify hooks via `--settings` so they layer on
// top of the user's real config without relocating CLAUDE_CONFIG_DIR (which would
// hide the Keychain login). Surface id reaches the hook through SPACESH_SURFACE_ID.
let mut args = spec.args.clone();
if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
args.extend(crate::hooks::claude_settings_args(&crate::hooks::spacesh_bin()));
}
let spawn_spec = SpawnSpec {
command: spec.command.clone(),
args: spec.args.clone(),
args,
cwd: std::path::PathBuf::from(&spec.cwd),
cols: spec.cols,
rows: spec.rows,