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:
@@ -6,3 +6,6 @@ app/dist/
|
|||||||
|
|
||||||
# Generated daemon sidecar for DMG bundling (make dmg)
|
# Generated daemon sidecar for DMG bundling (make dmg)
|
||||||
app/src-tauri/bin/
|
app/src-tauri/bin/
|
||||||
|
|
||||||
|
# Local notarization secrets (Apple ID / app-specific password)
|
||||||
|
.signing.env
|
||||||
|
|||||||
Generated
+5
-5
@@ -869,7 +869,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-cli"
|
name = "spacesh-cli"
|
||||||
version = "0.1.10"
|
version = "0.1.27"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -881,7 +881,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-core"
|
name = "spacesh-core"
|
||||||
version = "0.1.10"
|
version = "0.1.27"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -891,7 +891,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-proto"
|
name = "spacesh-proto"
|
||||||
version = "0.1.10"
|
version = "0.1.27"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -903,7 +903,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-pty"
|
name = "spacesh-pty"
|
||||||
version = "0.1.10"
|
version = "0.1.27"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -913,7 +913,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spaceshd"
|
name = "spaceshd"
|
||||||
version = "0.1.10"
|
version = "0.1.27"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ members = [
|
|||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "0.1.10"
|
version = "0.1.27"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ DMG_DIR := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/dm
|
|||||||
NATIVE_DMG_DIR := $(APP_DIR)/src-tauri/target/release/bundle/dmg
|
NATIVE_DMG_DIR := $(APP_DIR)/src-tauri/target/release/bundle/dmg
|
||||||
NATIVE_TRIPLE := $(shell rustc -vV 2>/dev/null | awk '/^host:/{print $$2}')
|
NATIVE_TRIPLE := $(shell rustc -vV 2>/dev/null | awk '/^host:/{print $$2}')
|
||||||
SIDECAR_DIR := $(APP_DIR)/src-tauri/bin
|
SIDECAR_DIR := $(APP_DIR)/src-tauri/bin
|
||||||
|
ENTITLEMENTS := $(APP_DIR)/src-tauri/Entitlements.plist
|
||||||
BUNDLE_CONFIG := src-tauri/tauri.bundle.conf.json
|
BUNDLE_CONFIG := src-tauri/tauri.bundle.conf.json
|
||||||
APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spaceshell.app
|
APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spaceshell.app
|
||||||
NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spaceshell.app
|
NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spaceshell.app
|
||||||
@@ -17,6 +18,34 @@ LANDING_VERSION := $(shell cat landing/VERSION 2>/dev/null || echo 0.0.0)
|
|||||||
REGISTRY ?= git.realmanual.ru
|
REGISTRY ?= git.realmanual.ru
|
||||||
REPO ?= spacesh
|
REPO ?= spacesh
|
||||||
|
|
||||||
|
# Stable code-signing identity. Without a STABLE signature the app is ad-hoc
|
||||||
|
# signed and its code identity changes every build, so macOS attributes child
|
||||||
|
# processes (the daemon → Claude Code) to a different "responsible app" each time:
|
||||||
|
# TCC permissions reset and agents lose their Keychain login on every rebuild.
|
||||||
|
# Defaults to the Developer ID (Team 3PNKDC6L42) — a stable designated requirement
|
||||||
|
# (anchor apple generic + TeamID) that Keychain/TCC trust survives across rebuilds.
|
||||||
|
# Override with `SIGN_IDENTITY="<cert name>" make reinstall`, or `SIGN_IDENTITY=`
|
||||||
|
# to fall back to ad-hoc. Tauri reads APPLE_SIGNING_IDENTITY for the bundle + sidecar.
|
||||||
|
SIGN_IDENTITY ?= Developer ID Application: Vassiliy Yegorov (3PNKDC6L42)
|
||||||
|
ifneq ($(strip $(SIGN_IDENTITY)),)
|
||||||
|
export APPLE_SIGNING_IDENTITY := $(SIGN_IDENTITY)
|
||||||
|
endif
|
||||||
|
|
||||||
|
# Notarization (required to distribute the DMG — Gatekeeper blocks un-notarized apps
|
||||||
|
# on other Macs). Secrets: put them in a gitignored `.signing.env` (make syntax,
|
||||||
|
# e.g. `APPLE_ID := you@example.com`) or pass on the CLI. NEVER commit them.
|
||||||
|
# APPLE_ID — your Apple ID email
|
||||||
|
# APPLE_PASSWORD — an app-specific password (appleid.apple.com → App-Specific Passwords)
|
||||||
|
# APPLE_TEAM_ID — 3PNKDC6L42 (defaulted below)
|
||||||
|
# When all three are present, `tauri build` auto-notarizes + staples the bundle.
|
||||||
|
-include .signing.env
|
||||||
|
APPLE_ID ?=
|
||||||
|
APPLE_PASSWORD ?=
|
||||||
|
APPLE_TEAM_ID ?= 3PNKDC6L42
|
||||||
|
ifneq ($(strip $(APPLE_ID)),)
|
||||||
|
export APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID
|
||||||
|
endif
|
||||||
|
|
||||||
# ---- Gitea generic package registry (versioned .dmg downloads) ----
|
# ---- Gitea generic package registry (versioned .dmg downloads) ----
|
||||||
GITEA_URL ?= https://git.realmanual.ru
|
GITEA_URL ?= https://git.realmanual.ru
|
||||||
GITEA_OWNER ?= pub
|
GITEA_OWNER ?= pub
|
||||||
@@ -53,7 +82,7 @@ bump: ## increment the patch version for BOTH the GUI (tauri.conf.json) and the
|
|||||||
@node scripts/bump_version.mjs
|
@node scripts/bump_version.mjs
|
||||||
|
|
||||||
.PHONY: dmg
|
.PHONY: dmg
|
||||||
dmg: bump targets ## bump version + build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
|
dmg: bump targets ## bump version + build universal .dmg (signed; notarized if .signing.env set)
|
||||||
# Tauri's universal build needs BOTH the per-arch sidecars (resolved during each
|
# Tauri's universal build needs BOTH the per-arch sidecars (resolved during each
|
||||||
# arch sub-build) AND a fat spaceshd-universal-apple-darwin (copied into the final
|
# arch sub-build) AND a fat spaceshd-universal-apple-darwin (copied into the final
|
||||||
# bundle — Tauri does not lipo sidecars itself). spaceshd ships inside
|
# bundle — Tauri does not lipo sidecars itself). spaceshd ships inside
|
||||||
@@ -102,7 +131,16 @@ install: kill-daemon ## install the native .app to /Applications, restart daemon
|
|||||||
rm -rf /Applications/spacesh.app /Applications/spaceshell.app # drop the pre-rename app too
|
rm -rf /Applications/spacesh.app /Applications/spaceshell.app # drop the pre-rename app too
|
||||||
cp -R "$(NATIVE_APP_BUNDLE)" /Applications/
|
cp -R "$(NATIVE_APP_BUNDLE)" /Applications/
|
||||||
xattr -dr com.apple.quarantine /Applications/spaceshell.app
|
xattr -dr com.apple.quarantine /Applications/spaceshell.app
|
||||||
|
ifneq ($(strip $(SIGN_IDENTITY)),)
|
||||||
|
# Belt-and-suspenders: re-sign inside-out with the stable identity so neither the
|
||||||
|
# embedded daemon nor the app is left ad-hoc if Tauri skipped the sidecar.
|
||||||
|
codesign --force --options runtime --timestamp --entitlements "$(ENTITLEMENTS)" --sign "$(SIGN_IDENTITY)" /Applications/spaceshell.app/Contents/MacOS/spaceshd
|
||||||
|
codesign --force --options runtime --timestamp --entitlements "$(ENTITLEMENTS)" --sign "$(SIGN_IDENTITY)" /Applications/spaceshell.app
|
||||||
|
@codesign -dvv /Applications/spaceshell.app 2>&1 | grep -E "TeamIdentifier|Signature" || true
|
||||||
|
endif
|
||||||
@echo "Installed (native). Quit & relaunch spaceshell; the bundled daemon restarts."
|
@echo "Installed (native). Quit & relaunch spaceshell; the bundled daemon restarts."
|
||||||
|
@echo "Tip: on first launch grant Full Disk Access (System Settings → Privacy & Security)"
|
||||||
|
@echo " so terminals inside the app can run tmutil / reach protected folders."
|
||||||
|
|
||||||
.PHONY: install-universal
|
.PHONY: install-universal
|
||||||
install-universal: kill-daemon ## install the universal .app to /Applications
|
install-universal: kill-daemon ## install the universal .app to /Applications
|
||||||
|
|||||||
Generated
+1
-1
@@ -3440,7 +3440,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-proto"
|
name = "spacesh-proto"
|
||||||
version = "0.1.10"
|
version = "0.1.26"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- WKWebView's JavaScriptCore needs JIT + writable/executable memory under the
|
||||||
|
hardened runtime required for notarization. -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<!-- The bundle embeds the spaceshd sidecar (same Team) and loads system/webview
|
||||||
|
components; relax library validation so loading never trips the hardened runtime. -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -534,6 +534,32 @@ pub fn open_external(url: String) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether spaceshell.app has Full Disk Access. Terminals spawned inside the app
|
||||||
|
/// inherit its TCC grants (the app is their "responsible process"), so without FDA
|
||||||
|
/// commands like `tmutil` fail. Probe: reading the user's TCC database requires FDA —
|
||||||
|
/// a permission error means we lack it; success (or the file being absent) means we
|
||||||
|
/// have it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn has_full_disk_access() -> bool {
|
||||||
|
let Some(home) = std::env::var_os("HOME") else { return false };
|
||||||
|
let probe = std::path::Path::new(&home).join("Library/Application Support/com.apple.TCC/TCC.db");
|
||||||
|
match std::fs::File::open(&probe) {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(e) => e.kind() == std::io::ErrorKind::NotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open System Settings → Privacy & Security → Full Disk Access so the user can add
|
||||||
|
/// spaceshell. macOS cannot grant this programmatically; we only deep-link the pane.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_full_disk_access_settings() -> Result<(), String> {
|
||||||
|
std::process::Command::new("open")
|
||||||
|
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// List the user's installed font families (CoreText) so Settings can offer any of
|
/// List the user's installed font families (CoreText) so Settings can offer any of
|
||||||
/// them for the terminal. Hidden system families (".SF NS" etc.) are dropped; the
|
/// them for the terminal. Hidden system families (".SF NS" etc.) are dropped; the
|
||||||
/// result is de-duplicated and sorted case-insensitively.
|
/// result is de-duplicated and sorted case-insensitively.
|
||||||
@@ -568,8 +594,29 @@ pub async fn set_config(
|
|||||||
font_size: Option<u16>,
|
font_size: Option<u16>,
|
||||||
theme: Option<String>,
|
theme: Option<String>,
|
||||||
accent: Option<String>,
|
accent: Option<String>,
|
||||||
|
background: Option<String>,
|
||||||
|
background_image: Option<String>,
|
||||||
|
log_shell_commands: Option<bool>,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent }).await.map_err(|e| e.to_string())?)
|
data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent, background, background_image, log_shell_commands }).await.map_err(|e| e.to_string())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a local image file and return it as a `data:` URL for use as a CSS
|
||||||
|
/// background. Kept in the GUI bridge (not the daemon) so the bytes load straight
|
||||||
|
/// into the webview without crossing the socket.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_image_data_url(path: String) -> Result<String, String> {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
|
||||||
|
let mime = match std::path::Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
|
Some("webp") => "image/webp",
|
||||||
|
Some("gif") => "image/gif",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
Ok(format!("data:{mime};base64,{b64}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -57,9 +57,12 @@ pub fn run() {
|
|||||||
bridge::which_agents,
|
bridge::which_agents,
|
||||||
bridge::check_update,
|
bridge::check_update,
|
||||||
bridge::open_external,
|
bridge::open_external,
|
||||||
|
bridge::has_full_disk_access,
|
||||||
|
bridge::open_full_disk_access_settings,
|
||||||
bridge::list_fonts,
|
bridge::list_fonts,
|
||||||
bridge::get_config,
|
bridge::get_config,
|
||||||
bridge::set_config,
|
bridge::set_config,
|
||||||
|
bridge::read_image_data_url,
|
||||||
bridge::shutdown_daemon,
|
bridge::shutdown_daemon,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"externalBin": ["bin/spaceshd"]
|
"externalBin": ["bin/spaceshd"],
|
||||||
|
"macOS": {
|
||||||
|
"entitlements": "Entitlements.plist"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "spaceshell",
|
"productName": "spaceshell",
|
||||||
"version": "0.1.10",
|
"version": "0.1.27",
|
||||||
"identifier": "xyz.spacesh.app",
|
"identifier": "xyz.spacesh.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
{
|
{
|
||||||
"title": "spaceshell",
|
"title": "spaceshell",
|
||||||
"width": 1100,
|
"width": 1100,
|
||||||
"height": 720
|
"height": 720,
|
||||||
|
"titleBarStyle": "Overlay",
|
||||||
|
"hiddenTitle": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+123
-18
@@ -10,11 +10,13 @@ import { ConfirmDelete } from "./ConfirmDelete";
|
|||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { EventCenter } from "./EventCenter";
|
import { EventCenter } from "./EventCenter";
|
||||||
import { maybeNotify } from "./notify";
|
import { maybeNotify } from "./notify";
|
||||||
import { COLORS, applyTheme, resolvePalette } from "./theme";
|
import { COLORS, FONT, applyTheme, resolvePalette } from "./theme";
|
||||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate } from "./socketBridge";
|
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate, splitSurface, closeSurfaceCmd, setZoom, readImageDataUrl, hasFullDiskAccess, openFullDiskAccessSettings } from "./socketBridge";
|
||||||
import type { EventRecord, DaemonHealth, ConfigView, UpdateInfo } from "./socketBridge";
|
import type { EventRecord, DaemonHealth, ConfigView, UpdateInfo } from "./socketBridge";
|
||||||
import { leafIds } from "./layoutTypes";
|
import { leafIds } from "./layoutTypes";
|
||||||
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||||
|
import { HOTKEYS, loadBindings, saveBindings, matches, hasModifier } from "./hotkeys";
|
||||||
|
import type { Bindings, HotkeyId } from "./hotkeys";
|
||||||
|
|
||||||
/** Read a boolean UI flag from localStorage, falling back to `def`. */
|
/** Read a boolean UI flag from localStorage, falling back to `def`. */
|
||||||
function loadFlag(key: string, def: boolean): boolean {
|
function loadFlag(key: string, def: boolean): boolean {
|
||||||
@@ -50,11 +52,20 @@ export function App() {
|
|||||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
const [searchSurfaceId, setSearchSurfaceId] = useState<string | null>(null);
|
const [searchSurfaceId, setSearchSurfaceId] = useState<string | null>(null);
|
||||||
const [searchNonce, setSearchNonce] = useState(0);
|
const [searchNonce, setSearchNonce] = useState(0);
|
||||||
|
const [bindings, setBindings] = useState<Bindings>(loadBindings);
|
||||||
|
// Full Disk Access: terminals inherit spaceshell's TCC grants, so without FDA
|
||||||
|
// tools like tmutil fail inside panels. Default true to avoid a flash before the probe.
|
||||||
|
const [fdaOk, setFdaOk] = useState(true);
|
||||||
|
const [fdaDismissed, setFdaDismissed] = useState(() => loadFlag("spacesh.fdaDismissed", false));
|
||||||
const activeRef = useRef<string | null>(null);
|
const activeRef = useRef<string | null>(null);
|
||||||
const effectiveFocusRef = useRef<string | null>(null);
|
const effectiveFocusRef = useRef<string | null>(null);
|
||||||
const wsRef = useRef<WorkspaceView[]>([]);
|
const wsRef = useRef<WorkspaceView[]>([]);
|
||||||
|
const leavesRef = useRef<string[]>([]);
|
||||||
|
const modalOpenRef = useRef(false);
|
||||||
|
const bindingsRef = useRef<Bindings>(bindings);
|
||||||
activeRef.current = activeId;
|
activeRef.current = activeId;
|
||||||
wsRef.current = workspaces;
|
wsRef.current = workspaces;
|
||||||
|
bindingsRef.current = bindings;
|
||||||
|
|
||||||
const seedEvents = useCallback(async () => {
|
const seedEvents = useCallback(async () => {
|
||||||
const log = await getEventLog();
|
const log = await getEventLog();
|
||||||
@@ -97,7 +108,7 @@ export function App() {
|
|||||||
void refresh();
|
void refresh();
|
||||||
void seedEvents();
|
void seedEvents();
|
||||||
void loadHealth();
|
void loadHealth();
|
||||||
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
|
void getConfig().then((c) => { setConfigState(c); }).catch(() => {});
|
||||||
const unlisten = onDaemonEvent((evt) => {
|
const unlisten = onDaemonEvent((evt) => {
|
||||||
if (evt.evt === "event") {
|
if (evt.evt === "event") {
|
||||||
const rec = evt.data.record;
|
const rec = evt.data.record;
|
||||||
@@ -116,9 +127,7 @@ export function App() {
|
|||||||
} else if (evt.evt === "exit") {
|
} else if (evt.evt === "exit") {
|
||||||
void refresh();
|
void refresh();
|
||||||
} else if (evt.evt === "config_changed") {
|
} else if (evt.evt === "config_changed") {
|
||||||
const c = evt.data.config;
|
setConfigState(evt.data.config);
|
||||||
setConfigState(c);
|
|
||||||
applyTheme(c.theme, c.accent);
|
|
||||||
} else {
|
} else {
|
||||||
void refresh();
|
void refresh();
|
||||||
}
|
}
|
||||||
@@ -128,7 +137,7 @@ export function App() {
|
|||||||
void refresh();
|
void refresh();
|
||||||
void seedEvents();
|
void seedEvents();
|
||||||
void loadHealth();
|
void loadHealth();
|
||||||
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
|
void getConfig().then((c) => { setConfigState(c); }).catch(() => {});
|
||||||
});
|
});
|
||||||
const reconnected = onDaemonRawEvent("spacesh:reconnected", () => {
|
const reconnected = onDaemonRawEvent("spacesh:reconnected", () => {
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
@@ -136,23 +145,85 @@ export function App() {
|
|||||||
void refresh();
|
void refresh();
|
||||||
void seedEvents();
|
void seedEvents();
|
||||||
void loadHealth();
|
void loadHealth();
|
||||||
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
|
void getConfig().then((c) => { setConfigState(c); }).catch(() => {});
|
||||||
});
|
});
|
||||||
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); void reconnected.then((f) => f()); };
|
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); void reconnected.then((f) => f()); };
|
||||||
}, [refresh, seedEvents, loadHealth]);
|
}, [refresh, seedEvents, loadHealth]);
|
||||||
|
|
||||||
|
// Cycle keyboard focus through the active workspace's panels.
|
||||||
|
const cycleFocus = useCallback((dir: 1 | -1) => {
|
||||||
|
const ls = leavesRef.current;
|
||||||
|
if (ls.length < 2) return;
|
||||||
|
const cur = effectiveFocusRef.current;
|
||||||
|
const i = Math.max(0, ls.indexOf(cur ?? ls[0]));
|
||||||
|
const next = ls[(i + dir + ls.length) % ls.length];
|
||||||
|
setFocusedId(next);
|
||||||
|
void focusSurface(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scrollback search is meaningless on an agent surface: claude/codex/… run a
|
||||||
|
// full-screen TUI in the alternate buffer that repaints every frame, so match
|
||||||
|
// decorations are drawn and immediately clobbered (the counter updates but no
|
||||||
|
// highlight shows). Gate the search affordance off for agent panels.
|
||||||
|
const isAgentSurface = useCallback((id: string | null): boolean => {
|
||||||
|
if (!id) return false;
|
||||||
|
const w = wsRef.current.find((ws) => id in ws.surfaces);
|
||||||
|
return !!w?.surfaces[id]?.spec?.agent_label;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Probe Full Disk Access on launch and again whenever the window regains focus
|
||||||
|
// (so granting it in System Settings and returning clears the banner immediately).
|
||||||
|
useEffect(() => {
|
||||||
|
const recheck = () => { void hasFullDiskAccess().then(setFdaOk); };
|
||||||
|
recheck();
|
||||||
|
window.addEventListener("focus", recheck);
|
||||||
|
return () => window.removeEventListener("focus", recheck);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Action dispatch table for hotkeys. Reads live state through refs so the
|
||||||
|
// window listener can mount once.
|
||||||
|
const actions = useMemo<Record<HotkeyId, () => void>>(() => ({
|
||||||
|
newWorkspace: () => setWizard(true),
|
||||||
|
openSettings: () => { if (config) setSettingsOpen(true); },
|
||||||
|
toggleSidebar: () => setSidebarOpen((v) => !v),
|
||||||
|
toggleEvents: () => setEventsOpen((v) => !v),
|
||||||
|
splitRight: () => { const f = effectiveFocusRef.current; if (f) void splitSurface(f, "right"); },
|
||||||
|
splitDown: () => { const f = effectiveFocusRef.current; if (f) void splitSurface(f, "down"); },
|
||||||
|
closePanel: () => { const f = effectiveFocusRef.current; if (f) void closeSurfaceCmd(f); },
|
||||||
|
focusNext: () => cycleFocus(1),
|
||||||
|
focusPrev: () => cycleFocus(-1),
|
||||||
|
zoomToggle: () => {
|
||||||
|
const a = wsRef.current.find((w) => w.id === activeRef.current);
|
||||||
|
if (!a) return;
|
||||||
|
void setZoom(a.id, a.zoomed ? null : effectiveFocusRef.current);
|
||||||
|
},
|
||||||
|
search: () => {
|
||||||
|
const f = effectiveFocusRef.current;
|
||||||
|
if (f && !isAgentSurface(f)) { setSearchSurfaceId(f); setSearchNonce((n) => n + 1); }
|
||||||
|
},
|
||||||
|
}), [cycleFocus, config, isAgentSurface]);
|
||||||
|
|
||||||
|
const actionsRef = useRef(actions);
|
||||||
|
actionsRef.current = actions;
|
||||||
|
|
||||||
|
// Central hotkey listener (mounted once). Skips while a modal is open, and only
|
||||||
|
// ever swallows keys that carry a modifier so terminal input is never stolen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
|
if (modalOpenRef.current) return;
|
||||||
if (activeRef.current && effectiveFocusRef.current) {
|
for (const h of HOTKEYS) {
|
||||||
|
const b = bindingsRef.current[h.id];
|
||||||
|
if (hasModifier(b) && matches(b, e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSearchSurfaceId(effectiveFocusRef.current); // anchor to the focused panel
|
e.stopPropagation(); // capture phase: keep the chord out of the focused terminal
|
||||||
setSearchNonce((n) => n + 1);
|
actionsRef.current[h.id]();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
// Capture phase so the chord is seen even when xterm has keyboard focus.
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey, true);
|
||||||
|
return () => window.removeEventListener("keydown", onKey, true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update check: once on launch, then every 6h.
|
// Update check: once on launch, then every 6h.
|
||||||
@@ -170,8 +241,29 @@ export function App() {
|
|||||||
const leaves = active ? leafIds(active.layout) : [];
|
const leaves = active ? leafIds(active.layout) : [];
|
||||||
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
|
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
|
||||||
effectiveFocusRef.current = effectiveFocus;
|
effectiveFocusRef.current = effectiveFocus;
|
||||||
|
leavesRef.current = leaves;
|
||||||
|
modalOpenRef.current = wizard || settingsOpen || !!pendingPreset || !!deleteTarget;
|
||||||
|
|
||||||
const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent) : null), [config?.theme, config?.accent]);
|
// Apply theme + background fill whenever appearance changes. A custom image is
|
||||||
|
// loaded (file → data URL) before re-applying so the panel glass and root fill
|
||||||
|
// paint together. Centralized here so every config source (initial load,
|
||||||
|
// reconnect, config_changed) flows through one place.
|
||||||
|
const [bgImage, setBgImage] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return;
|
||||||
|
let cancelled = false;
|
||||||
|
if (config.background === "custom" && config.background_image) {
|
||||||
|
void readImageDataUrl(config.background_image)
|
||||||
|
.then((url) => { if (!cancelled) { setBgImage(url); applyTheme(config.theme, config.accent, config.background, url); } })
|
||||||
|
.catch(() => { if (!cancelled) { setBgImage(null); applyTheme(config.theme, config.accent, "none", null); } });
|
||||||
|
} else {
|
||||||
|
setBgImage(null);
|
||||||
|
applyTheme(config.theme, config.accent, config.background, null);
|
||||||
|
}
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [config?.theme, config?.accent, config?.background, config?.background_image]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent, config.background) : null), [config?.theme, config?.accent, config?.background, bgImage]);
|
||||||
const termFont = useMemo(() => (config ? { family: config.font_family, size: config.font_size } : null), [config?.font_family, config?.font_size]);
|
const termFont = useMemo(() => (config ? { family: config.font_family, size: config.font_size } : null), [config?.font_family, config?.font_size]);
|
||||||
|
|
||||||
function selectWorkspace(id: string) {
|
function selectWorkspace(id: string) {
|
||||||
@@ -181,8 +273,21 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.appBg }}>
|
||||||
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} update={update} updateChecking={updateChecking} onCheckUpdate={() => { void runUpdateCheck(); }} />
|
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} update={update} updateChecking={updateChecking} onCheckUpdate={() => { void runUpdateCheck(); }} />
|
||||||
|
{!fdaOk && !fdaDismissed && (
|
||||||
|
<div style={{ position: "relative", zIndex: 25, display: "flex", alignItems: "center", gap: 12, padding: "8px 14px", background: "rgba(242,184,75,0.12)", borderBottom: `1px solid ${COLORS.stWait}`, fontFamily: FONT.ui, fontSize: 12, color: COLORS.textPrimary }}>
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
<b>Нет Full Disk Access.</b> Терминалы внутри spaceshell наследуют права приложения — без него команды вроде <code style={{ fontFamily: FONT.mono }}>tmutil</code> и доступ к защищённым папкам падают. Добавьте spaceshell в System Settings → Privacy & Security → Full Disk Access.
|
||||||
|
</span>
|
||||||
|
<button onClick={() => void openFullDiskAccessSettings()}
|
||||||
|
style={{ flex: "0 0 auto", padding: "5px 12px", background: COLORS.accent, color: COLORS.bgApp, border: "none", borderRadius: 7, fontSize: 12, fontWeight: 600, cursor: "pointer" }}>Открыть настройки</button>
|
||||||
|
<button onClick={() => void hasFullDiskAccess().then(setFdaOk)}
|
||||||
|
style={{ flex: "0 0 auto", padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12, cursor: "pointer" }}>Проверить снова</button>
|
||||||
|
<button onClick={() => { setFdaDismissed(true); saveFlag("spacesh.fdaDismissed", true); }} aria-label="Скрыть"
|
||||||
|
style={{ flex: "0 0 auto", padding: "5px 8px", background: "transparent", color: COLORS.textMuted, border: "none", borderRadius: 7, fontSize: 12, cursor: "pointer" }}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||||
<Sidebar railMode={!sidebarOpen} groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />
|
<Sidebar railMode={!sidebarOpen} groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
@@ -193,7 +298,7 @@ export function App() {
|
|||||||
const delta = target - leaves.length;
|
const delta = target - leaves.length;
|
||||||
if (delta <= 0) { void applyPreset(active.id, p, []); return; } // reshape only — no new panels
|
if (delta <= 0) { void applyPreset(active.id, p, []); return; } // reshape only — no new panels
|
||||||
setPendingPreset({ id: p, delta, base: leaves.length });
|
setPendingPreset({ id: p, delta, base: leaves.length });
|
||||||
}} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
|
}} onOpenSearch={() => { if (effectiveFocus && !isAgentSurface(effectiveFocus)) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} searchDisabled={isAgentSurface(effectiveFocus)} />
|
||||||
)}
|
)}
|
||||||
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||||
{active
|
{active
|
||||||
@@ -210,7 +315,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />}
|
{settingsOpen && config && <Settings config={config} health={health} bindings={bindings} onBindingsChange={(b) => { setBindings(b); saveBindings(b); }} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />}
|
||||||
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
|
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
|
||||||
{pendingPreset && active && (
|
{pendingPreset && active && (
|
||||||
<SurfacePicker
|
<SurfacePicker
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ import { COLORS, FONT } from "./theme";
|
|||||||
import { PresetPicker } from "./PresetPicker";
|
import { PresetPicker } from "./PresetPicker";
|
||||||
|
|
||||||
/** Top-of-grid toolbar: layout presets on the left, scrollback search on the right (search is a mock). */
|
/** Top-of-grid toolbar: layout presets on the left, scrollback search on the right (search is a mock). */
|
||||||
export function CenterToolbar({ selected, onSelect, onOpenSearch, paneCount }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void; paneCount: number }) {
|
export function CenterToolbar({ selected, onSelect, onOpenSearch, paneCount, searchDisabled = false }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void; paneCount: number; searchDisabled?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
|
<div style={{ position: "relative", zIndex: 20, display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, background: COLORS.elevatedGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
|
||||||
<PresetPicker selected={selected} onSelect={onSelect} minSlots={paneCount} />
|
<PresetPicker selected={selected} onSelect={onSelect} minSlots={paneCount} />
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
<div
|
<div
|
||||||
title="Search scrollback"
|
title={searchDisabled ? "Поиск недоступен в панели с агентом (полноэкранный TUI)" : "Search scrollback"}
|
||||||
onClick={onOpenSearch}
|
onClick={searchDisabled ? undefined : onOpenSearch}
|
||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 8px", borderRadius: 6,
|
display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 8px", borderRadius: 6,
|
||||||
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer",
|
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`,
|
||||||
|
cursor: searchDisabled ? "not-allowed" : "pointer", opacity: searchDisabled ? 0.4 : 1,
|
||||||
}}>
|
}}>
|
||||||
<Search size={12} color={COLORS.textMuted} />
|
<Search size={12} color={COLORS.textMuted} />
|
||||||
<span style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.textMuted }}>Search scrollback</span>
|
<span style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.textMuted }}>Search scrollback</span>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function EventCenter({
|
|||||||
: events;
|
: events;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
|
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
|
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
|
||||||
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
|
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const fontStackLE = (family: string | null) =>
|
|||||||
|
|
||||||
function xtermThemeLE(p: Record<string, string>) {
|
function xtermThemeLE(p: Record<string, string>) {
|
||||||
return {
|
return {
|
||||||
background: p["bg-panel"],
|
background: p["term-bg"] ?? p["bg-panel"],
|
||||||
foreground: p["text-primary"],
|
foreground: p["text-primary"],
|
||||||
cursor: p["text-primary"],
|
cursor: p["text-primary"],
|
||||||
selectionBackground: p["search-match"],
|
selectionBackground: p["search-match"],
|
||||||
@@ -140,6 +140,7 @@ function StoppedSnapshot({ surfaceId, font, palette }: { surfaceId: string; font
|
|||||||
fontFamily: fontStackLE(font?.family ?? null),
|
fontFamily: fontStackLE(font?.family ?? null),
|
||||||
fontSize: font?.size ?? 13,
|
fontSize: font?.size ?? 13,
|
||||||
theme: palette ? xtermThemeLE(palette) : undefined,
|
theme: palette ? xtermThemeLE(palette) : undefined,
|
||||||
|
allowTransparency: true, // term-bg may be transparent under a background theme
|
||||||
cursorBlink: false,
|
cursorBlink: false,
|
||||||
disableStdin: true,
|
disableStdin: true,
|
||||||
scrollback: 0,
|
scrollback: 0,
|
||||||
@@ -164,7 +165,8 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
|||||||
onMouseDown={() => onFocus(id)}
|
onMouseDown={() => onFocus(id)}
|
||||||
style={{
|
style={{
|
||||||
position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
|
position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
|
||||||
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
|
background: "transparent",
|
||||||
|
borderRadius: 8, overflow: "hidden",
|
||||||
// Constant 2px border, color-only on focus. A width change (1px<->2px)
|
// Constant 2px border, color-only on focus. A width change (1px<->2px)
|
||||||
// would resize the inner content box, fire ResizeObserver -> fit -> PTY
|
// would resize the inner content box, fire ResizeObserver -> fit -> PTY
|
||||||
// SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus
|
// SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus
|
||||||
@@ -173,7 +175,15 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
|||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Glass fill + blur as a layer BEHIND the content. The terminal's transparent
|
||||||
|
cells show this through. Crucially the terminal canvas is NOT a descendant
|
||||||
|
of a backdrop-filter element — under WKWebView that clips/smears the WebGL
|
||||||
|
canvas (first-glyph clip at column 0, smearing on scroll). With "none" the
|
||||||
|
glass is the solid bg-panel so the classic look is unchanged. */}
|
||||||
|
<div style={{ position: "absolute", inset: 0, zIndex: 0, background: COLORS.panelGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, pointerEvents: "none" }} />
|
||||||
|
<div style={{ position: "relative", zIndex: 1, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
{inner}
|
{inner}
|
||||||
|
</div>
|
||||||
{dropEdge && <DropIndicator edge={dropEdge} />}
|
{dropEdge && <DropIndicator edge={dropEdge} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -217,7 +227,7 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
|||||||
<div
|
<div
|
||||||
onMouseDown={(e) => { onFocus(id); onStartPanelDrag(id, e); }}
|
onMouseDown={(e) => { onFocus(id); onStartPanelDrag(id, e); }}
|
||||||
title="Drag to move this panel"
|
title="Drag to move this panel"
|
||||||
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.bgElevated, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }}
|
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.elevatedGlass, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }}
|
||||||
>
|
>
|
||||||
<GripVertical size={13} color={COLORS.textMuted} />
|
<GripVertical size={13} color={COLORS.textMuted} />
|
||||||
<StatusRing state={state} running={true} />
|
<StatusRing state={state} running={true} />
|
||||||
@@ -238,7 +248,7 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
|||||||
onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textMuted; }} />
|
onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textMuted; }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
<TerminalView key={id} surfaceId={id} font={font} palette={palette} />
|
<TerminalView key={id} surfaceId={id} font={font} palette={palette} focused={focused} />
|
||||||
</div>
|
</div>
|
||||||
{searchSurfaceId === id && (
|
{searchSurfaceId === id && (
|
||||||
<SearchBar surfaceId={id} reopenNonce={searchNonce} onClose={onCloseSearch} />
|
<SearchBar surfaceId={id} reopenNonce={searchNonce} onClose={onCloseSearch} />
|
||||||
|
|||||||
+100
-2
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { X, Search, Check } from "lucide-react";
|
import { X, Search, Check } from "lucide-react";
|
||||||
import { COLORS, FONT, ACCENTS } from "./theme";
|
import { COLORS, FONT, ACCENTS, BACKGROUNDS, CUSTOM_BACKGROUND } from "./theme";
|
||||||
import { setConfig, restartDaemon, listFonts } from "./socketBridge";
|
import { setConfig, restartDaemon, listFonts } from "./socketBridge";
|
||||||
import type { ConfigView, DaemonHealth } from "./socketBridge";
|
import type { ConfigView, DaemonHealth } from "./socketBridge";
|
||||||
|
import { HOTKEYS, defaultBindings, eventBinding, formatBinding, hasModifier } from "./hotkeys";
|
||||||
|
import type { Bindings, HotkeyId } from "./hotkeys";
|
||||||
|
|
||||||
// Pinned defaults shown first; the rest are the user's installed families (list_fonts).
|
// Pinned defaults shown first; the rest are the user's installed families (list_fonts).
|
||||||
const DEFAULT_FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
|
const DEFAULT_FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
|
||||||
@@ -70,7 +72,7 @@ function FontPicker({ value, onPick }: { value: string; onPick: (family: string)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) {
|
export function Settings({ config, health, bindings, onBindingsChange, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; bindings: Bindings; onBindingsChange: (b: Bindings) => void; onClose: () => void; onReload: () => void }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => { ref.current?.focus(); }, []);
|
useEffect(() => { ref.current?.focus(); }, []);
|
||||||
|
|
||||||
@@ -81,6 +83,10 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
|
|||||||
// Fix 3: controlled shell input — synced from config, committed on blur.
|
// Fix 3: controlled shell input — synced from config, committed on blur.
|
||||||
const [shellLocal, setShellLocal] = useState(config.default_shell);
|
const [shellLocal, setShellLocal] = useState(config.default_shell);
|
||||||
useEffect(() => { setShellLocal(config.default_shell); }, [config.default_shell]);
|
useEffect(() => { setShellLocal(config.default_shell); }, [config.default_shell]);
|
||||||
|
|
||||||
|
// Custom background image path — committed on blur (switches background to "custom").
|
||||||
|
const [bgPathLocal, setBgPathLocal] = useState(config.background_image);
|
||||||
|
useEffect(() => { setBgPathLocal(config.background_image); }, [config.background_image]);
|
||||||
return (
|
return (
|
||||||
<div onMouseDown={onClose} style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
<div onMouseDown={onClose} style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
<div ref={ref} tabIndex={-1} onMouseDown={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") onClose(); }}
|
<div ref={ref} tabIndex={-1} onMouseDown={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") onClose(); }}
|
||||||
@@ -121,16 +127,108 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Background</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginBottom: 10 }}>
|
||||||
|
{Object.entries(BACKGROUNDS).map(([id, bg]) => {
|
||||||
|
const selected = config.background === id;
|
||||||
|
return (
|
||||||
|
<button key={id} onClick={() => void setConfig({ background: id })} aria-label={bg.label} title={bg.label}
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4, padding: 0, cursor: "pointer", background: "transparent", border: "none" }}>
|
||||||
|
<div style={{ height: 40, borderRadius: 6, background: bg.swatch,
|
||||||
|
border: selected ? `2px solid ${COLORS.accent}` : `2px solid ${COLORS.borderSubtle}`, boxSizing: "border-box" }} />
|
||||||
|
<span style={{ fontSize: 10, color: selected ? COLORS.textPrimary : COLORS.textMuted, textAlign: "center", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{bg.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button onClick={() => { if (bgPathLocal) void setConfig({ background: CUSTOM_BACKGROUND, background_image: bgPathLocal }); }}
|
||||||
|
aria-label="Custom image" title="Custom image"
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4, padding: 0, cursor: "pointer", background: "transparent", border: "none" }}>
|
||||||
|
<div style={{ height: 40, borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", color: COLORS.textMuted, fontSize: 10, background: COLORS.bgPanel,
|
||||||
|
border: config.background === CUSTOM_BACKGROUND ? `2px solid ${COLORS.accent}` : `2px solid ${COLORS.borderSubtle}`, boxSizing: "border-box" }}>Image</div>
|
||||||
|
<span style={{ fontSize: 10, color: config.background === CUSTOM_BACKGROUND ? COLORS.textPrimary : COLORS.textMuted, textAlign: "center" }}>Custom</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input value={bgPathLocal} onChange={(e) => setBgPathLocal(e.target.value)}
|
||||||
|
onBlur={() => { if (bgPathLocal) void setConfig({ background: CUSTOM_BACKGROUND, background_image: bgPathLocal }); }}
|
||||||
|
placeholder="/path/to/wallpaper.png — image for the Custom theme"
|
||||||
|
style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8, fontSize: 12 }} />
|
||||||
|
|
||||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Default shell (empty = auto)</div>
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Default shell (empty = auto)</div>
|
||||||
<input value={shellLocal} onChange={(e) => setShellLocal(e.target.value)} onBlur={() => void setConfig({ default_shell: shellLocal })}
|
<input value={shellLocal} onChange={(e) => setShellLocal(e.target.value)} onBlur={() => void setConfig({ default_shell: shellLocal })}
|
||||||
style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }} />
|
style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }} />
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Events</div>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18, cursor: "pointer" }}>
|
||||||
|
<button role="switch" aria-checked={config.log_shell_commands} onClick={() => void setConfig({ log_shell_commands: !config.log_shell_commands })}
|
||||||
|
style={{ position: "relative", width: 38, height: 22, flex: "0 0 auto", borderRadius: 11, border: "none", cursor: "pointer",
|
||||||
|
background: config.log_shell_commands ? COLORS.accent : COLORS.bgElevated, transition: "background 0.15s" }}>
|
||||||
|
<span style={{ position: "absolute", top: 2, left: config.log_shell_commands ? 18 : 2, width: 18, height: 18, borderRadius: "50%", background: "#fff", transition: "left 0.15s" }} />
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 13, color: COLORS.textPrimary }}>
|
||||||
|
Log shell commands
|
||||||
|
<span style={{ display: "block", fontSize: 11, color: COLORS.textMuted }}>Off: only agent activity is logged & notified. Status rings still update.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<HotkeysSection bindings={bindings} onChange={onBindingsChange} />
|
||||||
|
|
||||||
<DaemonSection health={health} onReload={onReload} />
|
<DaemonSection health={health} onReload={onReload} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hotkeys list with click-to-record rebinding. */
|
||||||
|
function HotkeysSection({ bindings, onChange }: { bindings: Bindings; onChange: (b: Bindings) => void }) {
|
||||||
|
const [recordingId, setRecordingId] = useState<HotkeyId | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recordingId) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Escape") { setRecordingId(null); return; }
|
||||||
|
const b = eventBinding(e);
|
||||||
|
if (!b || !hasModifier(b)) return; // wait for a real chord with a modifier
|
||||||
|
onChange({ ...bindings, [recordingId]: b });
|
||||||
|
setRecordingId(null);
|
||||||
|
};
|
||||||
|
// Capture phase so we intercept before the modal's own key handling.
|
||||||
|
window.addEventListener("keydown", onKey, true);
|
||||||
|
return () => window.removeEventListener("keydown", onKey, true);
|
||||||
|
}, [recordingId, bindings, onChange]);
|
||||||
|
|
||||||
|
const groups = ["Workspace", "Panel"] as const;
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${COLORS.borderSubtle}` }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 12, color: COLORS.textSecondary, flex: 1 }}>Hotkeys</span>
|
||||||
|
<button onClick={() => onChange(defaultBindings())}
|
||||||
|
style={{ fontSize: 11, color: COLORS.textMuted, background: "transparent", border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, padding: "3px 8px", cursor: "pointer" }}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g} style={{ marginBottom: 6 }}>
|
||||||
|
<div style={{ fontSize: 10, textTransform: "uppercase", letterSpacing: "0.05em", color: COLORS.textMuted, margin: "6px 0 2px" }}>{g}</div>
|
||||||
|
{HOTKEYS.filter((h) => h.group === g).map((h) => (
|
||||||
|
<div key={h.id} style={{ display: "flex", alignItems: "center", height: 28 }}>
|
||||||
|
<span style={{ flex: 1, fontSize: 13, color: COLORS.textPrimary }}>{h.label}</span>
|
||||||
|
<button onClick={() => setRecordingId(h.id)}
|
||||||
|
style={{ minWidth: 70, fontFamily: FONT.mono, fontSize: 12, padding: "3px 10px", borderRadius: 6, cursor: "pointer",
|
||||||
|
background: recordingId === h.id ? COLORS.accent : COLORS.bgPanel,
|
||||||
|
color: recordingId === h.id ? COLORS.bgApp : COLORS.textPrimary,
|
||||||
|
border: `1px solid ${recordingId === h.id ? COLORS.accent : COLORS.borderStrong}` }}>
|
||||||
|
{recordingId === h.id ? "Press keys…" : formatBinding(bindings[h.id])}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function fmtUptime(ms: number): string {
|
function fmtUptime(ms: number): string {
|
||||||
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
||||||
if (s < 60) return `${s}s`;
|
if (s < 60) return `${s}s`;
|
||||||
|
|||||||
+2
-2
@@ -192,7 +192,7 @@ export function Sidebar({
|
|||||||
...ungrouped,
|
...ungrouped,
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 48, flex: "0 0 48px", background: COLORS.bgSidebar, height: "100%", padding: "10px 0", boxSizing: "border-box", borderRight: `1px solid ${COLORS.borderSubtle}`, gap: 8 }}>
|
<div style={{ position: "relative", zIndex: 20, display: "flex", flexDirection: "column", alignItems: "center", width: 48, flex: "0 0 48px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, height: "100%", padding: "10px 0", boxSizing: "border-box", borderRight: `1px solid ${COLORS.borderSubtle}`, gap: 8 }}>
|
||||||
<button onClick={onNew} title="New workspace"
|
<button onClick={onNew} title="New workspace"
|
||||||
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: 8, background: COLORS.bgElevated, border: `1px solid ${COLORS.borderStrong}`, color: COLORS.textPrimary, cursor: "pointer" }}>
|
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: 8, background: COLORS.bgElevated, border: `1px solid ${COLORS.borderStrong}`, color: COLORS.textPrimary, cursor: "pointer" }}>
|
||||||
<Plus size={15} />
|
<Plus size={15} />
|
||||||
@@ -216,7 +216,7 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box" }}>
|
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, height: "100%", padding: 14, boxSizing: "border-box" }}>
|
||||||
<button onClick={onNew}
|
<button onClick={onNew}
|
||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", height: 34, marginBottom: 16,
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", height: 34, marginBottom: 16,
|
||||||
|
|||||||
@@ -9,6 +9,27 @@ import { registerSearch, unregisterSearch } from "./searchRegistry";
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
// xterm.js auto-answers device queries (Device Attributes, cursor/status reports,
|
||||||
|
// OSC color queries, DECRPM mode reports) by emitting the reply through onData.
|
||||||
|
// But the daemon's alacritty grid is the authoritative emulator and already
|
||||||
|
// answers these on the PTY (see spacesh-core grid.rs `take_replies` →
|
||||||
|
// `write_input`). Forwarding xterm's duplicate — which arrives a full IPC
|
||||||
|
// roundtrip late — lands in the shell's input buffer after it stopped reading
|
||||||
|
// the reply, so the shell echoes it as literal escape gibberish and the prompt
|
||||||
|
// shifts. Drop these standalone reports; never the user's keystrokes/paste/mouse
|
||||||
|
// (mouse reports end in M/m and are real user input the program asked for).
|
||||||
|
function isDeviceReport(data: string): boolean {
|
||||||
|
if (data.charCodeAt(0) !== 0x1b) return false;
|
||||||
|
return (
|
||||||
|
/^\x1b\[[?>=]?[0-9;]*[cntR]$/.test(data) || // DA1/DA2 (c), DSR status (n), text-area/cell-size (t), cursor position (R)
|
||||||
|
/^\x1b\[\?[0-9;]*u$/.test(data) || // kitty keyboard QUERY reply (\x1b[?flags u) — NOT key input \x1b[<code>u
|
||||||
|
/^\x1b\[\?[0-9;]*\$[py]$/.test(data) || // DECRPM mode report
|
||||||
|
/^\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\)$/.test(data) || // OSC color / query reply (BEL- or ST-terminated)
|
||||||
|
/^\x1bP[\s\S]*\x1b\\$/.test(data) || // any DCS report (XTVERSION / DECRQSS / status string)
|
||||||
|
/^\x1b_[\s\S]*\x1b\\$/.test(data) // any APC report (kitty graphics)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Appended after the user font so Nerd Font icon glyphs (Private Use Area) render
|
// Appended after the user font so Nerd Font icon glyphs (Private Use Area) render
|
||||||
// via fallback instead of blank boxes, without changing the base monospace font.
|
// via fallback instead of blank boxes, without changing the base monospace font.
|
||||||
const NERD_FALLBACK = "'Symbols Nerd Font Mono'";
|
const NERD_FALLBACK = "'Symbols Nerd Font Mono'";
|
||||||
@@ -18,18 +39,25 @@ const fontStack = (family: string | null) =>
|
|||||||
|
|
||||||
function xtermTheme(p: Record<string, string>) {
|
function xtermTheme(p: Record<string, string>) {
|
||||||
return {
|
return {
|
||||||
background: p["bg-panel"],
|
background: p["term-bg"] ?? p["bg-panel"],
|
||||||
foreground: p["text-primary"],
|
foreground: p["text-primary"],
|
||||||
cursor: p["text-primary"],
|
cursor: p["text-primary"],
|
||||||
selectionBackground: p["search-match"],
|
selectionBackground: p["search-match"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null }) {
|
export function TerminalView({ surfaceId, font, palette, focused }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null; focused?: boolean }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
|
// A background theme makes term-bg fully transparent so the panel's glass fill
|
||||||
|
// shows through. allowTransparency is construction-time only, so it's part of the
|
||||||
|
// effect key to force a remount when it flips. WebGL stays on in both modes — the
|
||||||
|
// glass/blur lives on a sibling layer (see LayoutEngine), not an ancestor, so the
|
||||||
|
// WebGL canvas composites its transparent background without the WKWebView
|
||||||
|
// clipping/smearing artifacts that backdrop-filter ancestors cause.
|
||||||
|
const transparent = palette?.["term-bg"] === "rgba(0,0,0,0)";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
@@ -42,6 +70,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
|||||||
convertEol: false,
|
convertEol: false,
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
|
allowTransparency: transparent,
|
||||||
theme: palette ? xtermTheme(palette) : undefined,
|
theme: palette ? xtermTheme(palette) : undefined,
|
||||||
});
|
});
|
||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
@@ -83,6 +112,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
|||||||
|
|
||||||
// Input → daemon.
|
// Input → daemon.
|
||||||
const inputDisposable = term.onData((data) => {
|
const inputDisposable = term.onData((data) => {
|
||||||
|
if (isDeviceReport(data)) return; // daemon answers the PTY authoritatively; xterm's dup arrives late and echoes
|
||||||
void sendInput(surfaceId, encoder.encode(data));
|
void sendInput(surfaceId, encoder.encode(data));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +147,15 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
|||||||
fitRef.current = null;
|
fitRef.current = null;
|
||||||
webglRef.current = null;
|
webglRef.current = null;
|
||||||
};
|
};
|
||||||
}, [surfaceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [surfaceId, transparent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Keyboard focus cycling (cmd+]/[) only changes the focusedId state — it never
|
||||||
|
// touches the DOM, so the new panel's xterm textarea stays unfocused and keys
|
||||||
|
// keep flowing to the old terminal. Mouse clicks don't hit this because the
|
||||||
|
// click lands on the textarea directly. Drive xterm focus from the prop.
|
||||||
|
useEffect(() => {
|
||||||
|
if (focused) termRef.current?.focus();
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
// Live re-apply font and theme when config changes without remounting.
|
// Live re-apply font and theme when config changes without remounting.
|
||||||
// font and palette are memoized in App.tsx so stable identity = no spurious re-applies.
|
// font and palette are memoized in App.tsx so stable identity = no spurious re-applies.
|
||||||
|
|||||||
+15
-5
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown, CloudDownload, Download } from "lucide-react";
|
import { FolderGit2, PanelLeft, PanelRight, Bell, Settings, ChevronDown, CloudDownload, Download } from "lucide-react";
|
||||||
import { COLORS, FONT } from "./theme";
|
import { COLORS, FONT } from "./theme";
|
||||||
import type { WorkspaceView } from "./layoutTypes";
|
import type { WorkspaceView } from "./layoutTypes";
|
||||||
import { leafIds } from "./layoutTypes";
|
import { leafIds } from "./layoutTypes";
|
||||||
@@ -125,17 +125,28 @@ export function TopBar({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
// Native titlebar is a transparent Overlay (see tauri.conf.json), so the
|
||||||
|
// theme fill flows up to the very top and this bar IS the titlebar. Make it
|
||||||
|
// the OS drag region; child buttons keep their own clicks (Tauri only starts
|
||||||
|
// a drag on mousedown landing on the drag-region element itself).
|
||||||
|
data-tauri-drag-region
|
||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", height: 40, flex: "0 0 40px",
|
display: "flex", alignItems: "center", height: 40, flex: "0 0 40px",
|
||||||
padding: "0 14px", gap: 12, background: COLORS.bgApp,
|
// Left pad clears the macOS traffic lights overlaid in this strip.
|
||||||
|
padding: "0 14px 0 78px", gap: 12, background: COLORS.elevatedGlass,
|
||||||
|
backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur,
|
||||||
borderBottom: `1px solid ${COLORS.borderSubtle}`,
|
borderBottom: `1px solid ${COLORS.borderSubtle}`,
|
||||||
|
// The glass panels below use backdrop-filter, which creates stacking
|
||||||
|
// contexts that otherwise paint over this bar's popovers (update/bell).
|
||||||
|
// Lift the whole bar into its own context above the panel grid.
|
||||||
|
position: "relative", zIndex: 30,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left: sidebar toggle, flush to the left edge. */}
|
{/* Left: sidebar toggle, flush to the left edge. */}
|
||||||
<IconBtn icon={<PanelLeft size={15} />} onClick={onToggleSidebar} active={sidebarOpen} title="Toggle Sidebar" />
|
<IconBtn icon={<PanelLeft size={15} />} onClick={onToggleSidebar} active={sidebarOpen} title="Toggle Sidebar" />
|
||||||
|
|
||||||
{/* Workspace breadcrumb */}
|
{/* Workspace breadcrumb */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
<div data-tauri-drag-region style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||||
<FolderGit2 size={15} color={COLORS.textSecondary} />
|
<FolderGit2 size={15} color={COLORS.textSecondary} />
|
||||||
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 600, color: COLORS.textPrimary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 600, color: COLORS.textPrimary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
{active?.name ?? "spaceshell"}
|
{active?.name ?? "spaceshell"}
|
||||||
@@ -150,7 +161,7 @@ export function TopBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<div data-tauri-drag-region style={{ flex: 1, alignSelf: "stretch" }} />
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes spaceshBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
@keyframes spaceshBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
@@ -159,7 +170,6 @@ export function TopBar({
|
|||||||
|
|
||||||
{/* Right cluster */}
|
{/* Right cluster */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<IconBtn icon={<Search size={16} />} title="Search (mock)" />
|
|
||||||
<UpdateControl update={update} checking={updateChecking} onCheck={onCheckUpdate} />
|
<UpdateControl update={update} checking={updateChecking} onCheck={onCheckUpdate} />
|
||||||
<div style={{ position: "relative", display: "flex" }}>
|
<div style={{ position: "relative", display: "flex" }}>
|
||||||
<IconBtn icon={<Bell size={16} />} onClick={onShowEvents} active={eventsOpen} title="Open activity log" />
|
<IconBtn icon={<Bell size={16} />} onClick={onShowEvents} active={eventsOpen} title="Open activity log" />
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
|
|||||||
// Only offer agents the user actually has installed.
|
// Only offer agents the user actually has installed.
|
||||||
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
|
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
// Close on Escape regardless of which element holds focus (the inner div's
|
||||||
|
// onKeyDown misses it once focus moves to a preset button / select).
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); } };
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// GUI keyboard shortcuts. Pure front-end concern (the daemon/CLI don't use these),
|
||||||
|
// so bindings live in localStorage and are user-rebindable from Settings.
|
||||||
|
//
|
||||||
|
// All defaults use ⌘ so they never collide with characters typed into a terminal
|
||||||
|
// (xterm receives plain keys; ⌘-combos are app shortcuts). The central handler in
|
||||||
|
// App.tsx requires at least one modifier before it will swallow a key.
|
||||||
|
|
||||||
|
export type HotkeyId =
|
||||||
|
| "newWorkspace"
|
||||||
|
| "openSettings"
|
||||||
|
| "toggleSidebar"
|
||||||
|
| "toggleEvents"
|
||||||
|
| "splitRight"
|
||||||
|
| "splitDown"
|
||||||
|
| "closePanel"
|
||||||
|
| "focusNext"
|
||||||
|
| "focusPrev"
|
||||||
|
| "zoomToggle"
|
||||||
|
| "search";
|
||||||
|
|
||||||
|
export interface Binding {
|
||||||
|
meta?: boolean;
|
||||||
|
ctrl?: boolean;
|
||||||
|
alt?: boolean;
|
||||||
|
shift?: boolean;
|
||||||
|
key: string; // normalized via normKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotkeyDef {
|
||||||
|
id: HotkeyId;
|
||||||
|
label: string;
|
||||||
|
group: "Workspace" | "Panel";
|
||||||
|
def: Binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOTKEYS: HotkeyDef[] = [
|
||||||
|
{ id: "newWorkspace", label: "New workspace", group: "Workspace", def: { meta: true, key: "n" } },
|
||||||
|
{ id: "openSettings", label: "Open settings", group: "Workspace", def: { meta: true, key: "," } },
|
||||||
|
{ id: "toggleSidebar", label: "Toggle sidebar", group: "Workspace", def: { meta: true, key: "b" } },
|
||||||
|
{ id: "toggleEvents", label: "Toggle event center", group: "Workspace", def: { meta: true, key: "e" } },
|
||||||
|
{ id: "splitRight", label: "Split right", group: "Panel", def: { meta: true, key: "d" } },
|
||||||
|
{ id: "splitDown", label: "Split down", group: "Panel", def: { meta: true, shift: true, key: "d" } },
|
||||||
|
{ id: "closePanel", label: "Close panel", group: "Panel", def: { meta: true, key: "w" } },
|
||||||
|
{ id: "focusNext", label: "Focus next panel", group: "Panel", def: { meta: true, key: "]" } },
|
||||||
|
{ id: "focusPrev", label: "Focus previous panel", group: "Panel", def: { meta: true, key: "[" } },
|
||||||
|
{ id: "zoomToggle", label: "Toggle zoom", group: "Panel", def: { meta: true, key: "Enter" } },
|
||||||
|
{ id: "search", label: "Search scrollback", group: "Panel", def: { meta: true, key: "f" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORE_KEY = "spacesh.hotkeys";
|
||||||
|
|
||||||
|
export type Bindings = Record<HotkeyId, Binding>;
|
||||||
|
|
||||||
|
export function defaultBindings(): Bindings {
|
||||||
|
const out = {} as Bindings;
|
||||||
|
for (const h of HOTKEYS) out[h.id] = h.def;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadBindings(): Bindings {
|
||||||
|
const out = defaultBindings();
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const o = JSON.parse(raw) as Partial<Bindings>;
|
||||||
|
for (const h of HOTKEYS) if (o[h.id]) out[h.id] = o[h.id]!;
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBindings(b: Bindings): void {
|
||||||
|
try { localStorage.setItem(STORE_KEY, JSON.stringify(b)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable key token: single chars lowercased, named keys kept verbatim. */
|
||||||
|
export function normKey(k: string): string {
|
||||||
|
if (k === " ") return "Space";
|
||||||
|
return k.length === 1 ? k.toLowerCase() : k;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOD_KEYS = new Set(["Meta", "Control", "Alt", "Shift"]);
|
||||||
|
|
||||||
|
/** Capture a binding from a keydown event (null while only modifiers are held). */
|
||||||
|
export function eventBinding(e: KeyboardEvent): Binding | null {
|
||||||
|
if (MOD_KEYS.has(e.key)) return null;
|
||||||
|
return { meta: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, key: normKey(e.key) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matches(b: Binding, e: KeyboardEvent): boolean {
|
||||||
|
return (
|
||||||
|
!!b.meta === e.metaKey &&
|
||||||
|
!!b.ctrl === e.ctrlKey &&
|
||||||
|
!!b.alt === e.altKey &&
|
||||||
|
!!b.shift === e.shiftKey &&
|
||||||
|
b.key === normKey(e.key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasModifier(b: Binding): boolean {
|
||||||
|
return !!(b.meta || b.ctrl || b.alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable chord, e.g. "⌘⇧D". */
|
||||||
|
export function formatBinding(b: Binding): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (b.ctrl) parts.push("⌃");
|
||||||
|
if (b.alt) parts.push("⌥");
|
||||||
|
if (b.shift) parts.push("⇧");
|
||||||
|
if (b.meta) parts.push("⌘");
|
||||||
|
const key =
|
||||||
|
b.key === "Enter" ? "⏎" :
|
||||||
|
b.key === "Space" ? "␣" :
|
||||||
|
b.key === "ArrowUp" ? "↑" : b.key === "ArrowDown" ? "↓" :
|
||||||
|
b.key === "ArrowLeft" ? "←" : b.key === "ArrowRight" ? "→" :
|
||||||
|
b.key.length === 1 ? b.key.toUpperCase() : b.key;
|
||||||
|
return parts.join("") + key;
|
||||||
|
}
|
||||||
+22
-1
@@ -206,6 +206,16 @@ export async function listFonts(): Promise<string[]> {
|
|||||||
return await invoke<string[]>("list_fonts");
|
return await invoke<string[]>("list_fonts");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether spaceshell.app has Full Disk Access (terminals inherit its TCC grants). */
|
||||||
|
export async function hasFullDiskAccess(): Promise<boolean> {
|
||||||
|
try { return await invoke<boolean>("has_full_disk_access"); } catch { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep-link System Settings → Privacy & Security → Full Disk Access. */
|
||||||
|
export async function openFullDiskAccessSettings(): Promise<void> {
|
||||||
|
try { await invoke("open_full_disk_access_settings"); } catch { /* settings pane unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
/** Which of the given CLI candidates are actually installed on the daemon's spawn PATH. */
|
/** Which of the given CLI candidates are actually installed on the daemon's spawn PATH. */
|
||||||
export async function whichAgents(candidates: string[]): Promise<string[]> {
|
export async function whichAgents(candidates: string[]): Promise<string[]> {
|
||||||
const data = await invoke<{ available: string[] }>("which_agents", { candidates });
|
const data = await invoke<{ available: string[] }>("which_agents", { candidates });
|
||||||
@@ -224,22 +234,33 @@ export interface ConfigView {
|
|||||||
font_size: number;
|
font_size: number;
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
accent: string;
|
accent: string;
|
||||||
|
background: string;
|
||||||
|
background_image: string;
|
||||||
|
log_shell_commands: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getConfig(): Promise<ConfigView> {
|
export async function getConfig(): Promise<ConfigView> {
|
||||||
return await invoke<ConfigView>("get_config");
|
return await invoke<ConfigView>("get_config");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setConfig(patch: Partial<Pick<ConfigView, "default_shell" | "font_family" | "font_size" | "theme" | "accent">>): Promise<void> {
|
export async function setConfig(patch: Partial<Pick<ConfigView, "default_shell" | "font_family" | "font_size" | "theme" | "accent" | "background" | "background_image" | "log_shell_commands">>): Promise<void> {
|
||||||
await invoke("set_config", {
|
await invoke("set_config", {
|
||||||
defaultShell: patch.default_shell ?? null,
|
defaultShell: patch.default_shell ?? null,
|
||||||
fontFamily: patch.font_family ?? null,
|
fontFamily: patch.font_family ?? null,
|
||||||
fontSize: patch.font_size ?? null,
|
fontSize: patch.font_size ?? null,
|
||||||
theme: patch.theme ?? null,
|
theme: patch.theme ?? null,
|
||||||
accent: patch.accent ?? null,
|
accent: patch.accent ?? null,
|
||||||
|
background: patch.background ?? null,
|
||||||
|
backgroundImage: patch.background_image ?? null,
|
||||||
|
logShellCommands: patch.log_shell_commands ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read a local image file into a `data:` URL for use as a CSS background. */
|
||||||
|
export async function readImageDataUrl(path: string): Promise<string> {
|
||||||
|
return await invoke<string>("read_image_data_url", { path });
|
||||||
|
}
|
||||||
|
|
||||||
export async function shutdownDaemon(): Promise<void> {
|
export async function shutdownDaemon(): Promise<void> {
|
||||||
try { await invoke("shutdown_daemon"); } catch { /* connection drops as the daemon exits — expected */ }
|
try { await invoke("shutdown_daemon"); } catch { /* connection drops as the daemon exits — expected */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-7
@@ -7,6 +7,11 @@ export const COLORS = {
|
|||||||
bgElevated: "var(--c-bg-elevated)",
|
bgElevated: "var(--c-bg-elevated)",
|
||||||
bgHover: "var(--c-bg-hover)",
|
bgHover: "var(--c-bg-hover)",
|
||||||
bgPanel: "var(--c-bg-panel)",
|
bgPanel: "var(--c-bg-panel)",
|
||||||
|
panelGlass: "var(--c-panel-glass, var(--c-bg-panel))",
|
||||||
|
panelBlur: "var(--c-panel-blur, none)",
|
||||||
|
appBg: "var(--app-bg, var(--c-bg-app))",
|
||||||
|
elevatedGlass: "var(--c-elevated-glass, var(--c-bg-elevated))",
|
||||||
|
sidebarGlass: "var(--c-sidebar-glass, var(--c-bg-sidebar))",
|
||||||
bgSidebar: "var(--c-bg-sidebar)",
|
bgSidebar: "var(--c-bg-sidebar)",
|
||||||
borderStrong: "var(--c-border-strong)",
|
borderStrong: "var(--c-border-strong)",
|
||||||
borderSubtle: "var(--c-border-subtle)",
|
borderSubtle: "var(--c-border-subtle)",
|
||||||
@@ -92,15 +97,98 @@ export const ACCENTS: Record<string, string> = {
|
|||||||
|
|
||||||
export type ThemeName = "dark" | "light";
|
export type ThemeName = "dark" | "light";
|
||||||
|
|
||||||
/** Real color values for consumers that can't use var() (xterm). Keys are the kebab tokens plus "accent". */
|
// ---------------------------------------------------------------------------
|
||||||
export function resolvePalette(theme: ThemeName, accent: string): Record<string, string> {
|
// Background themes (Warp-style full-window fills)
|
||||||
const base = theme === "light" ? LIGHT : DARK;
|
// ---------------------------------------------------------------------------
|
||||||
return { ...base, accent: ACCENTS[accent] ?? ACCENTS.blue };
|
|
||||||
|
/**
|
||||||
|
* A background theme paints the WHOLE window behind every panel, instead of each
|
||||||
|
* terminal owning an opaque tile. Panels become semi-transparent glass (rgba over
|
||||||
|
* a backdrop blur) so the shared fill shows through uniformly across the grid.
|
||||||
|
*
|
||||||
|
* `css` is the CSS `background` value for the app root: a gradient, or `""` for
|
||||||
|
* the classic solid look ("none"), or the sentinel "custom" handled at apply time
|
||||||
|
* via a user-supplied image. `panelAlpha`/`blur` tune the glass over the fill.
|
||||||
|
*/
|
||||||
|
export interface BackgroundTheme {
|
||||||
|
label: string;
|
||||||
|
css: string; // app-root `background` value ("" = solid bg-app)
|
||||||
|
swatch: string; // gallery preview (gradient/color)
|
||||||
|
panelAlpha: number; // 0..1 — panel glass opacity over the fill
|
||||||
|
blur: number; // px — panel backdrop blur
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write the active palette to :root as --c-* custom properties. */
|
export const CUSTOM_BACKGROUND = "custom";
|
||||||
export function applyTheme(theme: ThemeName, accent: string): void {
|
|
||||||
const p = resolvePalette(theme, accent);
|
export const BACKGROUNDS: Record<string, BackgroundTheme> = {
|
||||||
|
none: { label: "None", css: "", swatch: DARK["bg-panel"], panelAlpha: 1, blur: 0 },
|
||||||
|
cyberwave: { label: "Cyber Wave", css: "linear-gradient(135deg,#06121f 0%,#0a2a3f 45%,#10183a 100%)", swatch: "linear-gradient(135deg,#06121f,#0a2a3f,#10183a)", panelAlpha: 0.46, blur: 9 },
|
||||||
|
phenomenon: { label: "Phenomenon", css: "radial-gradient(120% 120% at 80% 0%,#241a2e 0%,#15121d 45%,#0a0910 100%)", swatch: "radial-gradient(120% 120% at 80% 0%,#241a2e,#15121d,#0a0910)", panelAlpha: 0.5, blur: 8 },
|
||||||
|
dracula: { label: "Dracula", css: "linear-gradient(160deg,#282a36 0%,#21222c 60%,#1a1b23 100%)", swatch: "linear-gradient(160deg,#282a36,#21222c,#1a1b23)", panelAlpha: 0.58, blur: 6 },
|
||||||
|
aurora: { label: "Aurora", css: "linear-gradient(135deg,#0b3d2e 0%,#0a2c3a 40%,#241147 100%)", swatch: "linear-gradient(135deg,#0b3d2e,#0a2c3a,#241147)", panelAlpha: 0.44, blur: 10 },
|
||||||
|
ember: { label: "Ember", css: "linear-gradient(135deg,#2a0f12 0%,#3a1410 45%,#160a14 100%)", swatch: "linear-gradient(135deg,#2a0f12,#3a1410,#160a14)", panelAlpha: 0.5, blur: 8 },
|
||||||
|
referred: { label: "Referred", css: "linear-gradient(120deg,#b9c6ff 0%,#cdb6ff 40%,#ffd6e7 100%)", swatch: "linear-gradient(120deg,#b9c6ff,#cdb6ff,#ffd6e7)", panelAlpha: 0.34, blur: 12 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a background name to its theme, falling back to "none". */
|
||||||
|
export function backgroundFor(name: string): BackgroundTheme {
|
||||||
|
return BACKGROUNDS[name] ?? BACKGROUNDS.none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hex (#rrggbb) → rgba() string at the given alpha. */
|
||||||
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const h = hex.replace("#", "");
|
||||||
|
const r = parseInt(h.slice(0, 2), 16);
|
||||||
|
const g = parseInt(h.slice(2, 4), 16);
|
||||||
|
const b = parseInt(h.slice(4, 6), 16);
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real color values for consumers that can't use var() (xterm). Keys are the
|
||||||
|
* kebab tokens plus "accent" and "term-bg". When a background theme is active the
|
||||||
|
* terminal renders on transparent glass, so "term-bg" is fully transparent and
|
||||||
|
* the panel container supplies the visible tint.
|
||||||
|
*/
|
||||||
|
export function resolvePalette(theme: ThemeName, accent: string, background: string = "none"): Record<string, string> {
|
||||||
|
const base = theme === "light" ? LIGHT : DARK;
|
||||||
|
const active = background !== "none";
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
accent: ACCENTS[accent] ?? ACCENTS.blue,
|
||||||
|
"term-bg": active ? "rgba(0,0,0,0)" : base["bg-panel"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the active palette + background fill to :root as --c-* custom properties.
|
||||||
|
* `imageDataUrl` is only consulted when `background === "custom"`.
|
||||||
|
*/
|
||||||
|
export function applyTheme(theme: ThemeName, accent: string, background: string = "none", imageDataUrl: string | null = null): void {
|
||||||
|
const p = resolvePalette(theme, accent, background);
|
||||||
const root = document.documentElement.style;
|
const root = document.documentElement.style;
|
||||||
for (const [k, v] of Object.entries(p)) root.setProperty(`--c-${k}`, v);
|
for (const [k, v] of Object.entries(p)) root.setProperty(`--c-${k}`, v);
|
||||||
|
|
||||||
|
const bg = backgroundFor(background);
|
||||||
|
const base = theme === "light" ? LIGHT : DARK;
|
||||||
|
const active = background !== "none";
|
||||||
|
|
||||||
|
// App-root fill: custom image (cover) > gradient > solid bg-app.
|
||||||
|
const appBg = background === CUSTOM_BACKGROUND && imageDataUrl
|
||||||
|
? `center / cover no-repeat url("${imageDataUrl}")`
|
||||||
|
: bg.css || base["bg-app"];
|
||||||
|
root.setProperty("--app-bg", appBg);
|
||||||
|
|
||||||
|
// Panel glass: rgba(bg-panel, alpha) over the fill, plus optional backdrop blur.
|
||||||
|
// When inactive this is the solid bg-panel so the classic look is byte-identical.
|
||||||
|
const alpha = active ? (background === CUSTOM_BACKGROUND ? 0.5 : bg.panelAlpha) : 1;
|
||||||
|
const blur = active ? (background === CUSTOM_BACKGROUND ? 8 : bg.blur) : 0;
|
||||||
|
root.setProperty("--c-panel-glass", alpha < 1 ? hexToRgba(base["bg-panel"], alpha) : base["bg-panel"]);
|
||||||
|
root.setProperty("--c-panel-blur", blur > 0 ? `blur(${blur}px)` : "none");
|
||||||
|
|
||||||
|
// Chrome glass (TopBar / toolbar / sidebar / panel headers) — a touch more
|
||||||
|
// opaque than the panels so labels and controls stay legible over the fill.
|
||||||
|
const chromeAlpha = active ? Math.min(alpha + 0.22, 0.92) : 1;
|
||||||
|
root.setProperty("--c-elevated-glass", chromeAlpha < 1 ? hexToRgba(base["bg-elevated"], chromeAlpha) : base["bg-elevated"]);
|
||||||
|
root.setProperty("--c-sidebar-glass", chromeAlpha < 1 ? hexToRgba(base["bg-sidebar"], chromeAlpha) : base["bg-sidebar"]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use alacritty_terminal::event::{Event, EventListener};
|
|||||||
use alacritty_terminal::grid::Dimensions;
|
use alacritty_terminal::grid::Dimensions;
|
||||||
use alacritty_terminal::index::{Column, Line, Point};
|
use alacritty_terminal::index::{Column, Line, Point};
|
||||||
use alacritty_terminal::term::{Config, Term};
|
use alacritty_terminal::term::{Config, Term};
|
||||||
use alacritty_terminal::vte::ansi::Processor;
|
use alacritty_terminal::vte::ansi::{NamedColor, Processor, Rgb};
|
||||||
|
|
||||||
/// Fixed-size terminal dimensions for the daemon-side grid.
|
/// Fixed-size terminal dimensions for the daemon-side grid.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -25,23 +25,51 @@ impl Dimensions for GridSize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One escape sequence the terminal model wants written back to the PTY in
|
||||||
|
/// response to a query: either a ready-made byte reply (DA/DSR/etc.) or a color
|
||||||
|
/// report whose value must be resolved from the term's palette at drain time.
|
||||||
|
enum Reply {
|
||||||
|
Bytes(Vec<u8>),
|
||||||
|
Color(usize, Arc<dyn Fn(Rgb) -> String + Send + Sync>),
|
||||||
|
}
|
||||||
|
|
||||||
/// Collects the escape sequences the terminal model wants written back to the PTY
|
/// Collects the escape sequences the terminal model wants written back to the PTY
|
||||||
/// (Primary/Secondary Device Attributes, DSR cursor/status reports, etc.). Programs
|
/// (Primary/Secondary Device Attributes, DSR cursor/status reports, OSC color
|
||||||
/// like fish block on these replies at startup; with a void listener they hang ~2s
|
/// queries, etc.). Programs like fish, yazi and vim block on these replies at
|
||||||
/// and then warn ("could not read response to Primary Device Attribute query").
|
/// startup; with a void listener they hang ~2s and then warn ("could not read
|
||||||
|
/// response to Primary Device Attribute query") or render with the wrong theme.
|
||||||
|
///
|
||||||
|
/// The daemon is the authoritative responder for the PTY — the GUI's xterm.js is
|
||||||
|
/// display-only and must NOT echo its own replies back (its duplicate arrives an
|
||||||
|
/// IPC roundtrip late and gets typed into the shell as literal gibberish).
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct ReplyCollector {
|
pub struct ReplyCollector {
|
||||||
buf: Arc<Mutex<Vec<u8>>>,
|
buf: Arc<Mutex<Vec<Reply>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventListener for ReplyCollector {
|
impl EventListener for ReplyCollector {
|
||||||
fn send_event(&self, event: Event) {
|
fn send_event(&self, event: Event) {
|
||||||
if let Event::PtyWrite(text) = event {
|
let reply = match event {
|
||||||
|
Event::PtyWrite(text) => Reply::Bytes(text.into_bytes()),
|
||||||
|
// OSC 10/11/12 color query — alacritty defers the value to the embedder
|
||||||
|
// (us) via a formatter; resolve it against the palette in take_replies.
|
||||||
|
Event::ColorRequest(index, fmt) => Reply::Color(index, fmt),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
if let Ok(mut b) = self.buf.lock() {
|
if let Ok(mut b) = self.buf.lock() {
|
||||||
b.extend_from_slice(text.as_bytes());
|
b.push(reply);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fallback palette colors when a program queries one the term has not had set
|
||||||
|
/// explicitly. Matches the GUI's default theme so OSC 11 (background) reports a
|
||||||
|
/// dark color and light/dark detection in TUIs stays correct.
|
||||||
|
fn default_color(index: usize) -> Rgb {
|
||||||
|
if index == NamedColor::Background as usize { Rgb { r: 0x0a, g: 0x0d, b: 0x12 } }
|
||||||
|
else if index == NamedColor::Foreground as usize { Rgb { r: 0xe6, g: 0xed, b: 0xf3 } }
|
||||||
|
else if index == NamedColor::Cursor as usize { Rgb { r: 0xe6, g: 0xed, b: 0xf3 } }
|
||||||
|
else { Rgb { r: 0x80, g: 0x80, b: 0x80 } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns an alacritty terminal model and feeds raw PTY bytes into it.
|
/// Owns an alacritty terminal model and feeds raw PTY bytes into it.
|
||||||
@@ -68,11 +96,22 @@ impl GridSurface {
|
|||||||
/// far. The caller must write these back to the PTY for query-driven programs
|
/// far. The caller must write these back to the PTY for query-driven programs
|
||||||
/// (fish, vim, etc.) to proceed without timing out.
|
/// (fish, vim, etc.) to proceed without timing out.
|
||||||
pub fn take_replies(&mut self) -> Vec<u8> {
|
pub fn take_replies(&mut self) -> Vec<u8> {
|
||||||
match self.replies.buf.lock() {
|
let replies = {
|
||||||
Ok(mut b) => std::mem::take(&mut *b),
|
let Ok(mut b) = self.replies.buf.lock() else { return Vec::new(); };
|
||||||
Err(_) => Vec::new(),
|
std::mem::take(&mut *b)
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for reply in replies {
|
||||||
|
match reply {
|
||||||
|
Reply::Bytes(bytes) => out.extend_from_slice(&bytes),
|
||||||
|
Reply::Color(index, fmt) => {
|
||||||
|
let rgb = self.term.colors()[index].unwrap_or_else(|| default_color(index));
|
||||||
|
out.extend_from_slice(fmt(rgb).as_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resize(&mut self, cols: u16, rows: u16) {
|
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||||||
self.size = GridSize { cols: cols as usize, lines: rows as usize };
|
self.size = GridSize { cols: cols as usize, lines: rows as usize };
|
||||||
@@ -141,4 +180,15 @@ mod tests {
|
|||||||
// Replies are drained, not duplicated.
|
// Replies are drained, not duplicated.
|
||||||
assert!(g.take_replies().is_empty());
|
assert!(g.take_replies().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc_background_color_query_gets_a_reply() {
|
||||||
|
// yazi/vim send OSC 11 ("\x1b]11;?\x07") to detect the background color and
|
||||||
|
// block on the reply; the daemon must answer it authoritatively.
|
||||||
|
let mut g = GridSurface::new(20, 5);
|
||||||
|
g.feed(b"\x1b]11;?\x07");
|
||||||
|
let reply = String::from_utf8(g.take_replies()).unwrap();
|
||||||
|
assert!(reply.starts_with("\x1b]11;rgb:"), "expected an OSC 11 reply, got {reply:?}");
|
||||||
|
assert!(g.take_replies().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ pub struct ConfigView {
|
|||||||
pub font_size: u16,
|
pub font_size: u16,
|
||||||
pub theme: String,
|
pub theme: String,
|
||||||
pub accent: String,
|
pub accent: String,
|
||||||
|
/// Background-theme name (Warp-style full-window fill). "none" = solid.
|
||||||
|
#[serde(default = "default_background")]
|
||||||
|
pub background: String,
|
||||||
|
/// Absolute path to a custom background image (used when background == "custom").
|
||||||
|
#[serde(default)]
|
||||||
|
pub background_image: String,
|
||||||
|
/// Whether shell-command status (OSC 133) is logged; agent activity always is.
|
||||||
|
#[serde(default)]
|
||||||
|
pub log_shell_commands: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_background() -> String {
|
||||||
|
"none".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -18,6 +31,8 @@ mod tests {
|
|||||||
let c = ConfigView {
|
let c = ConfigView {
|
||||||
default_shell: "/bin/zsh".into(), font_family: "JetBrains Mono".into(),
|
default_shell: "/bin/zsh".into(), font_family: "JetBrains Mono".into(),
|
||||||
font_size: 13, theme: "dark".into(), accent: "blue".into(),
|
font_size: 13, theme: "dark".into(), accent: "blue".into(),
|
||||||
|
background: "none".into(), background_image: String::new(),
|
||||||
|
log_shell_commands: false,
|
||||||
};
|
};
|
||||||
let back: ConfigView = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
|
let back: ConfigView = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
|
||||||
assert_eq!(back, c);
|
assert_eq!(back, c);
|
||||||
|
|||||||
@@ -151,6 +151,12 @@ pub enum Cmd {
|
|||||||
theme: Option<String>,
|
theme: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
accent: Option<String>,
|
accent: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
background: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
background_image: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
log_shell_commands: Option<bool>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ impl PtyHandle {
|
|||||||
if !spec.env.iter().any(|(k, _)| k == "COLORTERM") {
|
if !spec.env.iter().any(|(k, _)| k == "COLORTERM") {
|
||||||
cmd.env("COLORTERM", "truecolor");
|
cmd.env("COLORTERM", "truecolor");
|
||||||
}
|
}
|
||||||
|
// Guarantee a UTF-8 locale. A GUI/launchd-launched daemon often has no LANG,
|
||||||
|
// so a directly-spawned agent (e.g. `claude`, no shell to set it) renders
|
||||||
|
// wide/box-drawing glyphs as mojibake — visible in Claude Code's usage bar.
|
||||||
|
// An interactive shell sets LANG in its init, which is why it looks fine there.
|
||||||
|
// Respect any inherited or caller-provided value.
|
||||||
|
if !spec.env.iter().any(|(k, _)| k == "LANG") && std::env::var_os("LANG").is_none() {
|
||||||
|
cmd.env("LANG", "en_US.UTF-8");
|
||||||
|
}
|
||||||
for (k, v) in &spec.env {
|
for (k, v) in &spec.env {
|
||||||
cmd.env(k, v);
|
cmd.env(k, v);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ pub struct AppearanceConfig {
|
|||||||
pub theme: Option<String>,
|
pub theme: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub accent: Option<String>,
|
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.
|
/// 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.
|
/// How often (seconds) the daemon dumps changed grids to disk.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub snapshot_interval_secs: Option<u64>,
|
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 {
|
impl Config {
|
||||||
@@ -62,9 +72,17 @@ impl Config {
|
|||||||
font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
|
font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
|
||||||
theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
|
theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
|
||||||
accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".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
|
/// Shell for a plain panel using THIS in-memory config
|
||||||
/// (env -> config -> passwd -> $SHELL -> /bin/sh).
|
/// (env -> config -> passwd -> $SHELL -> /bin/sh).
|
||||||
pub fn resolved_shell(&self) -> String {
|
pub fn resolved_shell(&self) -> String {
|
||||||
|
|||||||
@@ -18,35 +18,39 @@ fn dir_for(home: &PathBuf, sid: &SurfaceId) -> PathBuf {
|
|||||||
home.join(".spacesh").join("hooks").join(&sid.0)
|
home.join(".spacesh").join("hooks").join(&sid.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the settings.json contents wiring Stop/Notification/UserPromptSubmit
|
/// Our Stop/Notification/UserPromptSubmit hooks as a JSON value (the `hooks` object).
|
||||||
/// to `spacesh notify`. `spacesh_bin` is the absolute path to the CLI.
|
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 {
|
pub fn settings_json(spacesh_bin: &str) -> String {
|
||||||
let line = |state: &str| {
|
serde_json::to_string(&serde_json::json!({ "hooks": our_hooks(spacesh_bin) }))
|
||||||
format!(
|
.unwrap_or_else(|_| "{\"hooks\":{}}".to_string())
|
||||||
"{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}\"}}]}}"
|
|
||||||
)
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
"{{\"hooks\":{{\"Stop\":[{}],\"Notification\":[{}],\"UserPromptSubmit\":[{}]}}}}",
|
|
||||||
line("done"), line("wait"), line("work")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prepare the per-surface hook config; return env pairs to merge into the spawn.
|
/// Extra CLI args injecting our notify hooks into a spawned `claude`, leaving its
|
||||||
/// Best-effort: on any I/O error returns an empty vec (spawn proceeds without hooks).
|
/// default config dir (Keychain auth + onboarding) untouched.
|
||||||
pub fn prepare(sid: &SurfaceId, spacesh_bin: &str) -> Vec<(String, String)> {
|
pub fn claude_settings_args(spacesh_bin: &str) -> Vec<String> {
|
||||||
let Some(home) = dirs::home_dir() else { return vec![] };
|
vec!["--settings".to_string(), settings_json(spacesh_bin)]
|
||||||
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())]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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) {
|
pub fn cleanup(sid: &SurfaceId) {
|
||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
let _ = std::fs::remove_dir_all(dir_for(&home, sid));
|
let _ = std::fs::remove_dir_all(dir_for(&home, sid));
|
||||||
@@ -129,15 +133,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prepare_writes_config_and_cleanup_removes_it() {
|
fn claude_settings_args_pass_hooks_via_settings_flag() {
|
||||||
let sid = SurfaceId(format!("s_test_{}", std::process::id()));
|
let args = claude_settings_args("/abs/spacesh");
|
||||||
let env = prepare(&sid, "/abs/spacesh");
|
assert_eq!(args.len(), 2);
|
||||||
assert_eq!(env.len(), 1);
|
assert_eq!(args[0], "--settings");
|
||||||
assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR");
|
// The second arg is valid JSON carrying all three hook events.
|
||||||
let dir = std::path::PathBuf::from(&env[0].1);
|
let v: serde_json::Value = serde_json::from_str(&args[1]).unwrap();
|
||||||
assert!(dir.join("settings.json").exists());
|
assert!(v["hooks"]["Stop"].is_array());
|
||||||
cleanup(&sid);
|
assert!(args[1].contains("/abs/spacesh notify --surface $SPACESH_SURFACE_ID --state done"));
|
||||||
assert!(!dir.exists());
|
assert!(args[1].contains("--state wait"));
|
||||||
|
assert!(args[1].contains("--state work"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -189,11 +189,16 @@ async fn router(
|
|||||||
if reg.is_running(&surface_id) {
|
if reg.is_running(&surface_id) {
|
||||||
reg.set_state(&surface_id, state);
|
reg.set_state(&surface_id, state);
|
||||||
broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state }));
|
broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state }));
|
||||||
|
// 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) {
|
if let Some(kind) = kind_for_state(state) {
|
||||||
record_event(®, &mut event_log, &event_persister, &clients, &surface_id, kind);
|
record_event(®, &mut event_log, &event_persister, &clients, &surface_id, kind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ServerMsg::SnapshotTick => {
|
ServerMsg::SnapshotTick => {
|
||||||
let ids = reg.live_ids();
|
let ids = reg.live_ids();
|
||||||
for sid in ids {
|
for sid in ids {
|
||||||
@@ -275,9 +280,10 @@ fn err(id: u64, code: &str, msg: &str) -> Envelope {
|
|||||||
/// and whether a deterministic hook source is active.
|
/// and whether a deterministic hook source is active.
|
||||||
fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (Vec<(String, String)>, bool) {
|
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 (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());
|
// Hooks are injected as `--settings` CLI args at spawn (see spawn_from_spec),
|
||||||
let active = !env.is_empty();
|
// not via env — that keeps claude on its default config dir so Keychain login
|
||||||
(env, active)
|
// and onboarding survive. The agent still has a deterministic hook source.
|
||||||
|
(vec![], true)
|
||||||
} else if crate::hooks::is_zsh(&spec.command) {
|
} else if crate::hooks::is_zsh(&spec.command) {
|
||||||
(crate::hooks::shell_env(sid), false)
|
(crate::hooks::shell_env(sid), false)
|
||||||
} else {
|
} 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 let Some(v) = &theme {
|
||||||
if v != "dark" && v != "light" { let _ = out.send(err(id, "BAD_CONFIG", "theme")).await; return; }
|
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"];
|
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 !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();
|
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) = 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_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) = 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) = theme { next.appearance.theme = Some(v); }
|
||||||
if let Some(v) = accent { next.appearance.accent = 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();
|
let to_save = next.clone();
|
||||||
match tokio::task::spawn_blocking(move || to_save.save()).await {
|
match tokio::task::spawn_blocking(move || to_save.save()).await {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
@@ -1244,8 +1257,7 @@ mod tests {
|
|||||||
}).await;
|
}).await;
|
||||||
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
|
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
// Observer connection: receives all broadcast events (the detected-state path
|
// Observer connection: receives all broadcast events.
|
||||||
// flows through ServerMsg::StateDetected → record_event → Evt::Event).
|
|
||||||
let mut observer = UnixStream::connect(&sock).await.unwrap();
|
let mut observer = UnixStream::connect(&sock).await.unwrap();
|
||||||
|
|
||||||
// Drive the PTY output by attaching the control connection.
|
// Drive the PTY output by attaching the control connection.
|
||||||
@@ -1253,21 +1265,27 @@ mod tests {
|
|||||||
surface_id: spacesh_proto::SurfaceId(sid.clone()),
|
surface_id: spacesh_proto::SurfaceId(sid.clone()),
|
||||||
}).await;
|
}).await;
|
||||||
|
|
||||||
// Expect an Evt::Event (kind=done) for this surface from the OSC 133 Done detection.
|
// Shell-command status (OSC 133) updates the live status ring (Evt::State) but is
|
||||||
let mut found = None;
|
// 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);
|
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(3);
|
||||||
while tokio::time::Instant::now() < deadline {
|
while tokio::time::Instant::now() < deadline {
|
||||||
if let Ok(Ok(Some(env))) =
|
if let Ok(Ok(Some(env))) =
|
||||||
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await {
|
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await {
|
||||||
if let Envelope::Evt(Evt::Event { record }) = env {
|
match env {
|
||||||
if record.surface_id.0 == sid { found = Some(record); break; }
|
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!(saw_state_done, "expected an Evt::State(done) from the OSC 133 detection");
|
||||||
assert_eq!(rec.kind, spacesh_proto::event::EventKind::Done);
|
assert!(!saw_event, "shell-command events must not be logged when log_shell_commands is off");
|
||||||
assert!(!rec.read);
|
|
||||||
assert_eq!(rec.workspace_id.0, ws);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
|||||||
@@ -29,9 +29,16 @@ pub fn spawn_from_spec(
|
|||||||
) -> std::io::Result<SurfaceHandle> {
|
) -> std::io::Result<SurfaceHandle> {
|
||||||
let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())];
|
let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())];
|
||||||
env.extend(extra_env);
|
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 {
|
let spawn_spec = SpawnSpec {
|
||||||
command: spec.command.clone(),
|
command: spec.command.clone(),
|
||||||
args: spec.args.clone(),
|
args,
|
||||||
cwd: std::path::PathBuf::from(&spec.cwd),
|
cwd: std::path::PathBuf::from(&spec.cwd),
|
||||||
cols: spec.cols,
|
cols: spec.cols,
|
||||||
rows: spec.rows,
|
rows: spec.rows,
|
||||||
|
|||||||
Reference in New Issue
Block a user