Compare commits
8 Commits
0275c64ace
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
50996c929d
|
|||
|
ee845e15b3
|
|||
|
2ee2aaaffb
|
|||
|
333b051e9d
|
|||
|
372dd7123a
|
|||
|
39bb8e5fee
|
|||
|
d62628be8d
|
|||
|
3317b24d18
|
@@ -6,3 +6,6 @@ app/dist/
|
||||
|
||||
# Generated daemon sidecar for DMG bundling (make dmg)
|
||||
app/src-tauri/bin/
|
||||
|
||||
# Local notarization secrets (Apple ID / app-specific password)
|
||||
.signing.env
|
||||
|
||||
Generated
+5
-5
@@ -869,7 +869,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spacesh-cli"
|
||||
version = "0.1.3"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -881,7 +881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spacesh-core"
|
||||
version = "0.1.3"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"serde",
|
||||
@@ -891,7 +891,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spacesh-proto"
|
||||
version = "0.1.3"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"serde",
|
||||
@@ -903,7 +903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spacesh-pty"
|
||||
version = "0.1.3"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -913,7 +913,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spaceshd"
|
||||
version = "0.1.3"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ members = [
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.1.3"
|
||||
version = "0.1.30"
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -318,6 +318,7 @@ In `crates/spaceshd/src/config.rs`, add the struct and a default table, and exte
|
||||
const DEFAULT_RESUME: &[(&str, &[&str])] = &[
|
||||
("claude", &["--continue"]),
|
||||
("codex", &["resume"]),
|
||||
("deepseek", &["resume"]),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
|
||||
@@ -7,9 +7,10 @@ DMG_DIR := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/dm
|
||||
NATIVE_DMG_DIR := $(APP_DIR)/src-tauri/target/release/bundle/dmg
|
||||
NATIVE_TRIPLE := $(shell rustc -vV 2>/dev/null | awk '/^host:/{print $$2}')
|
||||
SIDECAR_DIR := $(APP_DIR)/src-tauri/bin
|
||||
ENTITLEMENTS := $(APP_DIR)/src-tauri/Entitlements.plist
|
||||
BUNDLE_CONFIG := src-tauri/tauri.bundle.conf.json
|
||||
APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spacesh.app
|
||||
NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spacesh.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
|
||||
APP_VERSION := $(shell node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version" 2>/dev/null || echo 0.0.0)
|
||||
|
||||
LANDING_IMAGE := spacesh-landing
|
||||
@@ -17,6 +18,40 @@ LANDING_VERSION := $(shell cat landing/VERSION 2>/dev/null || echo 0.0.0)
|
||||
REGISTRY ?= git.realmanual.ru
|
||||
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_URL ?= https://git.realmanual.ru
|
||||
GITEA_OWNER ?= pub
|
||||
GITEA_PKG ?= spacesh
|
||||
GITEA_TOKEN ?= # token with package:write; pass via env/CLI, never commit
|
||||
|
||||
# ---- Prod deploy (SSH) ----
|
||||
SSH_HOST ?= 192.168.8.5
|
||||
SSH_USER ?= root
|
||||
@@ -47,7 +82,7 @@ bump: ## increment the patch version for BOTH the GUI (tauri.conf.json) and the
|
||||
@node scripts/bump_version.mjs
|
||||
|
||||
.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
|
||||
# arch sub-build) AND a fat spaceshd-universal-apple-darwin (copied into the final
|
||||
# bundle — Tauri does not lipo sidecars itself). spaceshd ships inside
|
||||
@@ -93,16 +128,25 @@ kill-daemon: ## stop a running spaceshd so a freshly-built one takes over
|
||||
|
||||
.PHONY: install
|
||||
install: kill-daemon ## install the native .app to /Applications, restart daemon, clear quarantine
|
||||
rm -rf /Applications/spacesh.app
|
||||
rm -rf /Applications/spacesh.app /Applications/spaceshell.app # drop the pre-rename app too
|
||||
cp -R "$(NATIVE_APP_BUNDLE)" /Applications/
|
||||
xattr -dr com.apple.quarantine /Applications/spacesh.app
|
||||
@echo "Installed (native). Quit & relaunch spacesh; the bundled daemon restarts."
|
||||
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 "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
|
||||
install-universal: kill-daemon ## install the universal .app to /Applications
|
||||
rm -rf /Applications/spacesh.app
|
||||
rm -rf /Applications/spacesh.app /Applications/spaceshell.app
|
||||
cp -R "$(APP_BUNDLE)" /Applications/
|
||||
xattr -dr com.apple.quarantine /Applications/spacesh.app
|
||||
xattr -dr com.apple.quarantine /Applications/spaceshell.app
|
||||
|
||||
.PHONY: reinstall
|
||||
reinstall: app-bundle install ## fast self-update: build .app (no dmg), reinstall, restart daemon
|
||||
@@ -134,7 +178,7 @@ landing-push: landing-image ## tag & push the landing image to the registry
|
||||
# ---- Prod deploy ----
|
||||
|
||||
.PHONY: deploy-dmg
|
||||
deploy-dmg: dmg ## upload the .dmg + update manifest (latest.json) to the prod download dir
|
||||
deploy-dmg: dmg ## upload .dmg + manifest to prod, and publish the versioned .dmg to Gitea Packages
|
||||
@VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \
|
||||
printf '{"version":"%s","url":"https://spaceshell.ru/download/spacesh.dmg"}\n' "$$VER" > /tmp/spacesh-latest.json; \
|
||||
echo "manifest version → $$VER"
|
||||
@@ -142,6 +186,24 @@ deploy-dmg: dmg ## upload the .dmg + update manifest (latest.json) to the prod d
|
||||
scp $(SSH_OPTS) $(DMG_DIR)/*.dmg "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/spacesh.dmg"
|
||||
scp $(SSH_OPTS) /tmp/spacesh-latest.json "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/latest.json"
|
||||
@echo "Uploaded → https://spaceshell.ru/download/spacesh.dmg + latest.json"
|
||||
@$(MAKE) --no-print-directory _publish-dmg
|
||||
|
||||
.PHONY: publish-dmg
|
||||
publish-dmg: dmg _publish-dmg ## build + publish the versioned .dmg to the Gitea package registry
|
||||
|
||||
# Internal: upload the most recently built .dmg to Gitea's generic registry under
|
||||
# the current version. No build dependency, so deploy-dmg can call it without a
|
||||
# second bump/rebuild. Skips (doesn't fail) when GITEA_TOKEN is unset.
|
||||
.PHONY: _publish-dmg
|
||||
_publish-dmg:
|
||||
@if [ -z "$(GITEA_TOKEN)" ]; then echo "GITEA_TOKEN unset — skipping Gitea Packages publish"; exit 0; fi; \
|
||||
VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \
|
||||
DMG=$$(ls -t $(DMG_DIR)/*.dmg 2>/dev/null | head -1); \
|
||||
if [ -z "$$DMG" ]; then echo "no .dmg in $(DMG_DIR) — run make dmg first"; exit 1; fi; \
|
||||
URL="$(GITEA_URL)/api/packages/$(GITEA_OWNER)/generic/$(GITEA_PKG)/$$VER/spaceshell-$$VER.dmg"; \
|
||||
echo "Publishing $$DMG → $$URL"; \
|
||||
curl --fail-with-body -sS -H "Authorization: token $(GITEA_TOKEN)" --upload-file "$$DMG" "$$URL" && \
|
||||
echo "Published spaceshell-$$VER.dmg to Gitea Packages ($(GITEA_OWNER)/$(GITEA_PKG)@$$VER)"
|
||||
|
||||
.PHONY: deploy-stack
|
||||
deploy-stack: ## sync compose+proxy.conf to prod and pull/up (manual; CI does this on push)
|
||||
|
||||
Generated
+10
@@ -11,6 +11,7 @@
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
@@ -1354,6 +1355,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
|
||||
Generated
+143
-2
@@ -2955,6 +2955,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3433,6 +3457,7 @@ dependencies = [
|
||||
"spacesh-proto",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
@@ -3440,7 +3465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spacesh-proto"
|
||||
version = "0.1.3"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"serde",
|
||||
@@ -3743,6 +3768,48 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
@@ -4939,6 +5006,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -4987,13 +5063,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -5030,6 +5123,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5048,6 +5147,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5066,12 +5171,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5090,6 +5207,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5108,6 +5231,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -5126,6 +5255,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5144,6 +5279,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
|
||||
@@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
spacesh-proto = { path = "../../crates/spacesh-proto" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -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>
|
||||
@@ -7,11 +7,14 @@
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"core:app:default",
|
||||
"core:resources:default",
|
||||
"core:menu:default",
|
||||
"core:tray:default",
|
||||
"notification:default",
|
||||
"window-state:default"
|
||||
"window-state:default",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability for spacesh app","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:default","window-state:default"]}}
|
||||
{"default":{"identifier":"default","description":"Default capability for spacesh app","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-start-dragging","core:window:allow-start-resize-dragging","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:default","window-state:default","dialog:allow-open"]}}
|
||||
@@ -2192,6 +2192,72 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-message",
|
||||
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-message",
|
||||
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2192,6 +2192,72 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-message",
|
||||
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-message",
|
||||
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -534,6 +534,32 @@ pub fn open_external(url: String) -> Result<(), String> {
|
||||
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
|
||||
/// them for the terminal. Hidden system families (".SF NS" etc.) are dropped; the
|
||||
/// result is de-duplicated and sorted case-insensitively.
|
||||
@@ -568,8 +594,29 @@ pub async fn set_config(
|
||||
font_size: Option<u16>,
|
||||
theme: Option<String>,
|
||||
accent: Option<String>,
|
||||
background: Option<String>,
|
||||
background_image: Option<String>,
|
||||
log_shell_commands: Option<bool>,
|
||||
) -> 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]
|
||||
|
||||
@@ -8,6 +8,7 @@ pub fn run() {
|
||||
// Persist + restore the window's size, position and maximized state across restarts.
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
// Connect the bridge on a tokio runtime, then manage it.
|
||||
@@ -57,9 +58,12 @@ pub fn run() {
|
||||
bridge::which_agents,
|
||||
bridge::check_update,
|
||||
bridge::open_external,
|
||||
bridge::has_full_disk_access,
|
||||
bridge::open_full_disk_access_settings,
|
||||
bridge::list_fonts,
|
||||
bridge::get_config,
|
||||
bridge::set_config,
|
||||
bridge::read_image_data_url,
|
||||
bridge::shutdown_daemon,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"externalBin": ["bin/spaceshd"]
|
||||
"externalBin": ["bin/spaceshd"],
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "spacesh",
|
||||
"version": "0.1.3",
|
||||
"productName": "spaceshell",
|
||||
"version": "0.1.30",
|
||||
"identifier": "xyz.spacesh.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -12,9 +12,11 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "spacesh",
|
||||
"title": "spaceshell",
|
||||
"width": 1100,
|
||||
"height": 720
|
||||
"height": 720,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+146
-19
@@ -4,15 +4,19 @@ import { Sidebar } from "./Sidebar";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { CenterToolbar } from "./CenterToolbar";
|
||||
import { Wizard } from "./Wizard";
|
||||
import { SurfacePicker } from "./SurfacePicker";
|
||||
import { PRESETS } from "./PresetPicker";
|
||||
import { ConfirmDelete } from "./ConfirmDelete";
|
||||
import { Settings } from "./Settings";
|
||||
import { EventCenter } from "./EventCenter";
|
||||
import { maybeNotify } from "./notify";
|
||||
import { COLORS, applyTheme, resolvePalette } from "./theme";
|
||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate } from "./socketBridge";
|
||||
import { COLORS, FONT, applyTheme, resolvePalette } from "./theme";
|
||||
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 { leafIds } 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`. */
|
||||
function loadFlag(key: string, def: boolean): boolean {
|
||||
@@ -31,6 +35,8 @@ export function App() {
|
||||
const [states, setStates] = useState<Record<string, SurfaceState>>({});
|
||||
const [events, setEvents] = useState<EventRecord[]>([]);
|
||||
const [wizard, setWizard] = useState(false);
|
||||
// Pending additive preset awaiting the per-panel "what to open" choice.
|
||||
const [pendingPreset, setPendingPreset] = useState<{ id: string; delta: number; base: number } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<WorkspaceView | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
|
||||
@@ -46,11 +52,20 @@ export function App() {
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const [searchSurfaceId, setSearchSurfaceId] = useState<string | null>(null);
|
||||
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 effectiveFocusRef = useRef<string | null>(null);
|
||||
const wsRef = useRef<WorkspaceView[]>([]);
|
||||
const leavesRef = useRef<string[]>([]);
|
||||
const modalOpenRef = useRef(false);
|
||||
const bindingsRef = useRef<Bindings>(bindings);
|
||||
activeRef.current = activeId;
|
||||
wsRef.current = workspaces;
|
||||
bindingsRef.current = bindings;
|
||||
|
||||
const seedEvents = useCallback(async () => {
|
||||
const log = await getEventLog();
|
||||
@@ -93,7 +108,7 @@ export function App() {
|
||||
void refresh();
|
||||
void seedEvents();
|
||||
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) => {
|
||||
if (evt.evt === "event") {
|
||||
const rec = evt.data.record;
|
||||
@@ -112,9 +127,7 @@ export function App() {
|
||||
} else if (evt.evt === "exit") {
|
||||
void refresh();
|
||||
} else if (evt.evt === "config_changed") {
|
||||
const c = evt.data.config;
|
||||
setConfigState(c);
|
||||
applyTheme(c.theme, c.accent);
|
||||
setConfigState(evt.data.config);
|
||||
} else {
|
||||
void refresh();
|
||||
}
|
||||
@@ -124,7 +137,7 @@ export function App() {
|
||||
void refresh();
|
||||
void seedEvents();
|
||||
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", () => {
|
||||
setConnected(true);
|
||||
@@ -132,23 +145,85 @@ export function App() {
|
||||
void refresh();
|
||||
void seedEvents();
|
||||
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()); };
|
||||
}, [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(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
|
||||
if (activeRef.current && effectiveFocusRef.current) {
|
||||
if (modalOpenRef.current) return;
|
||||
for (const h of HOTKEYS) {
|
||||
const b = bindingsRef.current[h.id];
|
||||
if (hasModifier(b) && matches(b, e)) {
|
||||
e.preventDefault();
|
||||
setSearchSurfaceId(effectiveFocusRef.current); // anchor to the focused panel
|
||||
setSearchNonce((n) => n + 1);
|
||||
e.stopPropagation(); // capture phase: keep the chord out of the focused terminal
|
||||
actionsRef.current[h.id]();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// Capture phase so the chord is seen even when xterm has keyboard focus.
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, []);
|
||||
|
||||
// Update check: once on launch, then every 6h.
|
||||
@@ -166,8 +241,29 @@ export function App() {
|
||||
const leaves = active ? leafIds(active.layout) : [];
|
||||
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
|
||||
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]);
|
||||
|
||||
function selectWorkspace(id: string) {
|
||||
@@ -177,17 +273,36 @@ export function App() {
|
||||
}
|
||||
|
||||
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(); }} />
|
||||
{!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 }}>
|
||||
<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 }}>
|
||||
{active && (
|
||||
<CenterToolbar selected="" paneCount={leaves.length} onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
|
||||
<CenterToolbar selected="" paneCount={leaves.length} onSelect={(p) => {
|
||||
if (!active) return;
|
||||
const target = PRESETS.find((x) => x.id === p)?.slots ?? leaves.length;
|
||||
const delta = target - leaves.length;
|
||||
if (delta <= 0) { void applyPreset(active.id, p, []); return; } // reshape only — no new panels
|
||||
setPendingPreset({ id: p, delta, base: leaves.length });
|
||||
}} onOpenSearch={() => { if (effectiveFocus && !isAgentSurface(effectiveFocus)) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} searchDisabled={isAgentSurface(effectiveFocus)} />
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
{active
|
||||
? <LayoutEngine key={connEpoch} workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} font={termFont} palette={termPalette} />
|
||||
? <LayoutEngine key={connEpoch} workspaceId={active.id} workspaceName={active.name} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} font={termFont} palette={termPalette} />
|
||||
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace — create one to begin.</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,8 +315,20 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
</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)} />}
|
||||
{pendingPreset && active && (
|
||||
<SurfacePicker
|
||||
count={pendingPreset.delta}
|
||||
onCancel={() => setPendingPreset(null)}
|
||||
onConfirm={(specs) => {
|
||||
const padded = [...Array(pendingPreset.base).fill({}), ...specs]; // align to daemon's slots.get(existing.len()+j)
|
||||
const wsId = active.id;
|
||||
setPendingPreset(null);
|
||||
void applyPreset(wsId, pendingPreset.id, padded).then(() => void refresh());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleteTarget && (
|
||||
<ConfirmDelete
|
||||
name={deleteTarget.name}
|
||||
|
||||
@@ -3,17 +3,18 @@ import { COLORS, FONT } from "./theme";
|
||||
import { PresetPicker } from "./PresetPicker";
|
||||
|
||||
/** 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 (
|
||||
<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} />
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
title="Search scrollback"
|
||||
onClick={onOpenSearch}
|
||||
title={searchDisabled ? "Поиск недоступен в панели с агентом (полноэкранный TUI)" : "Search scrollback"}
|
||||
onClick={searchDisabled ? undefined : onOpenSearch}
|
||||
style={{
|
||||
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} />
|
||||
<span style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.textMuted }}>Search scrollback</span>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function EventCenter({
|
||||
: events;
|
||||
|
||||
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 }}>
|
||||
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
|
||||
<span
|
||||
|
||||
+31
-18
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Maximize2, Minimize2, RotateCw, GripVertical, Play } from "lucide-react";
|
||||
import { Maximize2, Minimize2, RotateCw, GripVertical, Play, X } from "lucide-react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { StatusRing } from "./StatusRing";
|
||||
import { COLORS, FONT, STATE_COLOR } from "./theme";
|
||||
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
|
||||
import { setRatios, restartSurface, setZoom, moveSurface, attachSurface, detachSurface } from "./socketBridge";
|
||||
import { setRatios, restartSurface, setZoom, moveSurface, attachSurface, detachSurface, closeSurfaceCmd } from "./socketBridge";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
layout: LayoutNode | null;
|
||||
/** surface_id -> running flag, from the latest status/events. */
|
||||
running: Record<string, boolean>;
|
||||
@@ -37,13 +38,7 @@ function edgeAt(clientX: number, clientY: number, r: DOMRect): Edge {
|
||||
return (Object.keys(d) as Edge[]).reduce((a, b) => (d[b] < d[a] ? b : a), "left");
|
||||
}
|
||||
|
||||
/** Collapse an absolute cwd into a ~/<leaf> style label for the panel header. */
|
||||
function shortPath(cwd: string): string {
|
||||
const leaf = cwd.split("/").filter(Boolean).pop();
|
||||
return leaf ? `~/${leaf}` : cwd;
|
||||
}
|
||||
|
||||
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Props) {
|
||||
export function LayoutEngine({ workspaceId, workspaceName, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Props) {
|
||||
// Panel drag-to-reorder. Implemented with raw pointer events rather than the
|
||||
// HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses.
|
||||
const [drop, setDrop] = useState<DropTarget | null>(null);
|
||||
@@ -81,7 +76,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f
|
||||
if (!layout) {
|
||||
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace — apply a preset to add panels.</div>;
|
||||
}
|
||||
const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette };
|
||||
const shared = { workspaceId, workspaceName, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette };
|
||||
if (zoomed) {
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
|
||||
@@ -97,7 +92,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f
|
||||
}
|
||||
|
||||
interface NodeProps {
|
||||
workspaceId: string; node: LayoutNode; path: number[];
|
||||
workspaceId: string; workspaceName: string; node: LayoutNode; path: number[];
|
||||
running: Record<string, boolean>; states: Record<string, SurfaceState>;
|
||||
surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void;
|
||||
zoomed: string | null;
|
||||
@@ -124,7 +119,7 @@ const fontStackLE = (family: string | null) =>
|
||||
|
||||
function xtermThemeLE(p: Record<string, string>) {
|
||||
return {
|
||||
background: p["bg-panel"],
|
||||
background: p["term-bg"] ?? p["bg-panel"],
|
||||
foreground: p["text-primary"],
|
||||
cursor: p["text-primary"],
|
||||
selectionBackground: p["search-match"],
|
||||
@@ -140,6 +135,7 @@ function StoppedSnapshot({ surfaceId, font, palette }: { surfaceId: string; font
|
||||
fontFamily: fontStackLE(font?.family ?? null),
|
||||
fontSize: font?.size ?? 13,
|
||||
theme: palette ? xtermThemeLE(palette) : undefined,
|
||||
allowTransparency: true, // term-bg may be transparent under a background theme
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
scrollback: 0,
|
||||
@@ -154,7 +150,7 @@ function StoppedSnapshot({ surfaceId, font, palette }: { surfaceId: string; font
|
||||
return <div ref={hostRef} style={{ position: "absolute", inset: 0, opacity: 0.45, pointerEvents: "none" }} />;
|
||||
}
|
||||
|
||||
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit<NodeProps, "node" | "path"> & { id: string }) {
|
||||
function Leaf({ id, workspaceId, workspaceName, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit<NodeProps, "node" | "path"> & { id: string }) {
|
||||
const focused = focusedId === id;
|
||||
const dropEdge = drop && drop.id === id ? drop.edge : null;
|
||||
|
||||
@@ -164,7 +160,8 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
||||
onMouseDown={() => onFocus(id)}
|
||||
style={{
|
||||
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)
|
||||
// would resize the inner content box, fire ResizeObserver -> fit -> PTY
|
||||
// SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus
|
||||
@@ -173,7 +170,15 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
{inner}
|
||||
{/* 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}
|
||||
</div>
|
||||
{dropEdge && <DropIndicator edge={dropEdge} />}
|
||||
</div>
|
||||
);
|
||||
@@ -193,6 +198,10 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<RotateCw size={13} /> Restart fresh
|
||||
</button>
|
||||
<button onClick={() => void closeSurfaceCmd(id)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<X size={13} /> Close
|
||||
</button>
|
||||
{zoomed === id && (
|
||||
<button onClick={() => void setZoom(workspaceId, null)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
@@ -213,12 +222,12 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
||||
<div
|
||||
onMouseDown={(e) => { onFocus(id); onStartPanelDrag(id, e); }}
|
||||
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} />
|
||||
<StatusRing state={state} running={true} />
|
||||
<span style={{ fontFamily: FONT.mono, fontSize: 12, fontWeight: 600, color: COLORS.textPrimary }}>{agent}</span>
|
||||
{spec?.cwd && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{shortPath(spec.cwd)}</span>}
|
||||
{workspaceName && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{workspaceName}</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ display: "flex", alignItems: "center", height: 16, padding: "0 7px", borderRadius: 8, background: "#000", fontFamily: FONT.mono, fontSize: 10, fontWeight: 600, color: STATE_COLOR[state] }}>
|
||||
{state}
|
||||
@@ -228,9 +237,13 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
||||
onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} />
|
||||
: <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom"
|
||||
onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
|
||||
<X size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Close panel"
|
||||
onMouseDown={(e) => { e.stopPropagation(); void closeSurfaceCmd(id); }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = COLORS.stError; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textMuted; }} />
|
||||
</div>
|
||||
<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>
|
||||
{searchSurfaceId === id && (
|
||||
<SearchBar surfaceId={id} reopenNonce={searchNonce} onClose={onCloseSearch} />
|
||||
|
||||
+100
-2
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "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 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).
|
||||
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);
|
||||
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.
|
||||
const [shellLocal, setShellLocal] = useState(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 (
|
||||
<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(); }}
|
||||
@@ -121,16 +127,108 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
|
||||
))}
|
||||
</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>
|
||||
<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 }} />
|
||||
|
||||
<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} />
|
||||
</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 {
|
||||
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
|
||||
+2
-2
@@ -192,7 +192,7 @@ export function Sidebar({
|
||||
...ungrouped,
|
||||
];
|
||||
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"
|
||||
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} />
|
||||
@@ -216,7 +216,7 @@ export function Sidebar({
|
||||
}
|
||||
|
||||
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}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", height: 34, marginBottom: 16,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { whichAgents } from "./socketBridge";
|
||||
import { KNOWN_AGENTS, SHELL, CUSTOM, agentLabel, specForChoice } from "./agents";
|
||||
|
||||
type SlotSpec = { command?: string; args?: string[] };
|
||||
|
||||
/**
|
||||
* Asks what to open in each new panel before a preset spawns it: Terminal
|
||||
* (shell), one of the installed CLIs (claude/codex/gemini/deepseek), or a
|
||||
* custom command. `count` is the number of new panels the preset will add.
|
||||
*/
|
||||
export function SurfacePicker({ count, onConfirm, onCancel }: { count: number; onConfirm: (specs: SlotSpec[]) => void; onCancel: () => void }) {
|
||||
const [installed, setInstalled] = useState<string[]>([]);
|
||||
const [choices, setChoices] = useState<string[]>([]);
|
||||
const [customCmds, setCustomCmds] = useState<string[]>([]);
|
||||
const choiceList = [SHELL, ...installed, CUSTOM];
|
||||
|
||||
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
|
||||
|
||||
function confirm() {
|
||||
const specs = Array.from({ length: count }, (_, i) => specForChoice(choices[i] ?? SHELL, customCmds[i] ?? ""));
|
||||
onConfirm(specs);
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") { e.preventDefault(); onCancel(); }
|
||||
else if (e.key === "Enter" && (e.target as HTMLElement).tagName !== "SELECT") { e.preventDefault(); confirm(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={onCancel}
|
||||
style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<div
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={onKeyDown}
|
||||
style={{ width: 420, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>{count > 1 ? `Open ${count} new panels` : "Open new panel"}</div>
|
||||
<div style={{ fontSize: 12, color: "#8B97A6", marginBottom: 16 }}>Choose what to run in each new panel.</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: count > 1 ? "1fr 1fr" : "1fr", gap: 8, marginBottom: 20 }}>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const val = choices[i] ?? SHELL;
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<select value={val} onChange={(e) => setChoices((c) => { const n = [...c]; n[i] = e.target.value; return n; })}
|
||||
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
|
||||
{choiceList.map((c) => <option key={c} value={c}>{agentLabel(c)}</option>)}
|
||||
</select>
|
||||
{val === CUSTOM && (
|
||||
<input value={customCmds[i] ?? ""} placeholder="e.g. npm run dev" autoFocus
|
||||
onChange={(e) => setCustomCmds((c) => { const n = [...c]; n[i] = e.target.value; return n; })}
|
||||
style={{ padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #4C8DFF", borderRadius: 6, fontFamily: "monospace", fontSize: 12 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
|
||||
<button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button>
|
||||
<button onClick={confirm} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700 }}>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,27 @@ import { registerSearch, unregisterSearch } from "./searchRegistry";
|
||||
const decoder = new TextDecoder();
|
||||
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
|
||||
// via fallback instead of blank boxes, without changing the base monospace font.
|
||||
const NERD_FALLBACK = "'Symbols Nerd Font Mono'";
|
||||
@@ -18,18 +39,25 @@ const fontStack = (family: string | null) =>
|
||||
|
||||
function xtermTheme(p: Record<string, string>) {
|
||||
return {
|
||||
background: p["bg-panel"],
|
||||
background: p["term-bg"] ?? p["bg-panel"],
|
||||
foreground: p["text-primary"],
|
||||
cursor: p["text-primary"],
|
||||
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 termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | 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(() => {
|
||||
if (!ref.current) return;
|
||||
@@ -42,6 +70,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
convertEol: false,
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
allowTransparency: transparent,
|
||||
theme: palette ? xtermTheme(palette) : undefined,
|
||||
});
|
||||
termRef.current = term;
|
||||
@@ -83,6 +112,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
|
||||
// Input → daemon.
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -117,7 +147,15 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
fitRef.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.
|
||||
// font and palette are memoized in App.tsx so stable identity = no spurious re-applies.
|
||||
|
||||
+16
-6
@@ -1,5 +1,5 @@
|
||||
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 type { WorkspaceView } from "./layoutTypes";
|
||||
import { leafIds } from "./layoutTypes";
|
||||
@@ -125,20 +125,31 @@ export function TopBar({
|
||||
}) {
|
||||
return (
|
||||
<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={{
|
||||
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}`,
|
||||
// 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. */}
|
||||
<IconBtn icon={<PanelLeft size={15} />} onClick={onToggleSidebar} active={sidebarOpen} title="Toggle Sidebar" />
|
||||
|
||||
{/* 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} />
|
||||
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 600, color: COLORS.textPrimary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{active?.name ?? "spacesh"}
|
||||
{active?.name ?? "spaceshell"}
|
||||
</span>
|
||||
{active && (
|
||||
<>
|
||||
@@ -150,7 +161,7 @@ export function TopBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
<div data-tauri-drag-region style={{ flex: 1, alignSelf: "stretch" }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes spaceshBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
@@ -159,7 +170,6 @@ export function TopBar({
|
||||
|
||||
{/* Right cluster */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<IconBtn icon={<Search size={16} />} title="Search (mock)" />
|
||||
<UpdateControl update={update} checking={updateChecking} onCheck={onCheckUpdate} />
|
||||
<div style={{ position: "relative", display: "flex" }}>
|
||||
<IconBtn icon={<Bell size={16} />} onClick={onShowEvents} active={eventsOpen} title="Open activity log" />
|
||||
|
||||
+26
-17
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { PresetPicker, PRESETS } from "./PresetPicker";
|
||||
import { openWorkspace, applyPreset, whichAgents } from "./socketBridge";
|
||||
|
||||
// Agents we know about; only the installed ones are offered (probed via whichAgents).
|
||||
const KNOWN_AGENTS = ["claude", "codex", "gemini"];
|
||||
const CUSTOM = "custom…";
|
||||
import { KNOWN_AGENTS, SHELL, CUSTOM, agentLabel, specForChoice } from "./agents";
|
||||
|
||||
export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) {
|
||||
const [path, setPath] = useState(".");
|
||||
@@ -16,7 +14,7 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pathRef = useRef<HTMLInputElement>(null);
|
||||
const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1;
|
||||
const agentChoices = ["shell", ...installed, CUSTOM];
|
||||
const agentChoices = [SHELL, ...installed, CUSTOM];
|
||||
|
||||
// Grab focus on open — otherwise keystrokes leak to the xterm panel behind us
|
||||
// (its helper textarea sits at z-index 1000 and keeps the live focus).
|
||||
@@ -28,21 +26,29 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
|
||||
// Only offer agents the user actually has installed.
|
||||
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
|
||||
|
||||
// Native folder picker — fills the path field with the chosen absolute directory.
|
||||
async function browse() {
|
||||
try {
|
||||
const picked = await openDialog({ directory: true, multiple: false, title: "Select project folder" });
|
||||
if (typeof picked === "string") setPath(picked);
|
||||
} catch { /* user cancelled or dialog unavailable */ }
|
||||
}
|
||||
|
||||
// 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() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const ws = await openWorkspace(path);
|
||||
const slotSpecs = Array.from({ length: slots }, (_, i) => {
|
||||
const a = agents[i] ?? "shell";
|
||||
if (a === "shell") return {};
|
||||
if (a === CUSTOM) {
|
||||
const parts = (customCmds[i] ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
return parts.length ? { command: parts[0], args: parts.slice(1) } : {};
|
||||
}
|
||||
return { command: a };
|
||||
});
|
||||
const slotSpecs = Array.from({ length: slots }, (_, i) => specForChoice(agents[i] ?? SHELL, customCmds[i] ?? ""));
|
||||
await applyPreset(ws, preset, slotSpecs);
|
||||
onDone(ws);
|
||||
} catch (e) {
|
||||
@@ -70,18 +76,21 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>New workspace</div>
|
||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Project folder</label>
|
||||
<input ref={pathRef} value={path} onChange={(e) => setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
|
||||
<div style={{ display: "flex", gap: 8, margin: "6px 0 16px" }}>
|
||||
<input ref={pathRef} value={path} onChange={(e) => setPath(e.target.value)} style={{ flex: 1, minWidth: 0, padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
|
||||
<button onClick={() => void browse()} style={{ flex: "0 0 auto", padding: "8px 14px", background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8, fontWeight: 600 }}>Browse…</button>
|
||||
</div>
|
||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Layout</label>
|
||||
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
|
||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}>
|
||||
{Array.from({ length: slots }, (_, i) => {
|
||||
const val = agents[i] ?? "shell";
|
||||
const val = agents[i] ?? SHELL;
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<select value={val} onChange={(e) => setAgents((a) => { const n = [...a]; n[i] = e.target.value; return n; })}
|
||||
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
|
||||
{agentChoices.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
{agentChoices.map((c) => <option key={c} value={c}>{agentLabel(c)}</option>)}
|
||||
</select>
|
||||
{val === CUSTOM && (
|
||||
<input value={customCmds[i] ?? ""} placeholder="e.g. npm run dev"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Launchable agents/CLIs offered when opening a new panel. Only the installed
|
||||
// ones are surfaced (probed via whichAgents); "shell" and "custom…" are always
|
||||
// available. Keep this list as the single source of truth — Wizard and
|
||||
// SurfacePicker both consume it.
|
||||
export const KNOWN_AGENTS = ["claude", "codex", "gemini", "deepseek", "opencode"];
|
||||
export const SHELL = "shell";
|
||||
export const CUSTOM = "custom…";
|
||||
|
||||
/** Human label for an agent choice (the shell is presented as "Terminal"). */
|
||||
export function agentLabel(choice: string): string {
|
||||
return choice === SHELL ? "Terminal" : choice;
|
||||
}
|
||||
|
||||
/** Map a picker choice (+ optional custom command line) to an applyPreset slot spec. */
|
||||
export function specForChoice(choice: string, custom: string): { command?: string; args?: string[] } {
|
||||
if (choice === SHELL) return {};
|
||||
if (choice === CUSTOM) {
|
||||
const parts = (custom ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
return parts.length ? { command: parts[0], args: parts.slice(1) } : {};
|
||||
}
|
||||
return { command: choice };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export async function whichAgents(candidates: string[]): Promise<string[]> {
|
||||
const data = await invoke<{ available: string[] }>("which_agents", { candidates });
|
||||
@@ -224,22 +234,33 @@ export interface ConfigView {
|
||||
font_size: number;
|
||||
theme: "dark" | "light";
|
||||
accent: string;
|
||||
background: string;
|
||||
background_image: string;
|
||||
log_shell_commands: boolean;
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<ConfigView> {
|
||||
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", {
|
||||
defaultShell: patch.default_shell ?? null,
|
||||
fontFamily: patch.font_family ?? null,
|
||||
fontSize: patch.font_size ?? null,
|
||||
theme: patch.theme ?? 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> {
|
||||
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)",
|
||||
bgHover: "var(--c-bg-hover)",
|
||||
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)",
|
||||
borderStrong: "var(--c-border-strong)",
|
||||
borderSubtle: "var(--c-border-subtle)",
|
||||
@@ -92,15 +97,98 @@ export const ACCENTS: Record<string, string> = {
|
||||
|
||||
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> {
|
||||
const base = theme === "light" ? LIGHT : DARK;
|
||||
return { ...base, accent: ACCENTS[accent] ?? ACCENTS.blue };
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background themes (Warp-style full-window fills)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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 function applyTheme(theme: ThemeName, accent: string): void {
|
||||
const p = resolvePalette(theme, accent);
|
||||
export const CUSTOM_BACKGROUND = "custom";
|
||||
|
||||
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;
|
||||
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::index::{Column, Line, Point};
|
||||
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.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -25,25 +25,53 @@ 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
|
||||
/// (Primary/Secondary Device Attributes, DSR cursor/status reports, etc.). Programs
|
||||
/// like fish block on these replies at startup; with a void listener they hang ~2s
|
||||
/// and then warn ("could not read response to Primary Device Attribute query").
|
||||
/// (Primary/Secondary Device Attributes, DSR cursor/status reports, OSC color
|
||||
/// queries, etc.). Programs like fish, yazi and vim block on these replies at
|
||||
/// 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)]
|
||||
pub struct ReplyCollector {
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
buf: Arc<Mutex<Vec<Reply>>>,
|
||||
}
|
||||
|
||||
impl EventListener for ReplyCollector {
|
||||
fn send_event(&self, event: Event) {
|
||||
if let Event::PtyWrite(text) = event {
|
||||
if let Ok(mut b) = self.buf.lock() {
|
||||
b.extend_from_slice(text.as_bytes());
|
||||
}
|
||||
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() {
|
||||
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.
|
||||
pub struct GridSurface {
|
||||
term: Term<ReplyCollector>,
|
||||
@@ -68,10 +96,21 @@ impl GridSurface {
|
||||
/// far. The caller must write these back to the PTY for query-driven programs
|
||||
/// (fish, vim, etc.) to proceed without timing out.
|
||||
pub fn take_replies(&mut self) -> Vec<u8> {
|
||||
match self.replies.buf.lock() {
|
||||
Ok(mut b) => std::mem::take(&mut *b),
|
||||
Err(_) => Vec::new(),
|
||||
let replies = {
|
||||
let Ok(mut b) = self.replies.buf.lock() else { return 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) {
|
||||
@@ -141,4 +180,15 @@ mod tests {
|
||||
// Replies are drained, not duplicated.
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,32 @@ pub fn remove_leaf(root: LayoutNode, target: &SurfaceId) -> Option<LayoutNode> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop duplicate leaves, keeping the first (left-to-right) occurrence of each
|
||||
/// surface id; collapses now-single-child splits. Returns None if empty.
|
||||
///
|
||||
/// Heals a tree corrupted by a duplicate surface id (e.g. an id re-minted after
|
||||
/// a daemon restart before the counter fix), which otherwise renders the same
|
||||
/// panel twice and confuses focus/search/output routing keyed by surface id.
|
||||
pub fn dedupe_leaves(root: LayoutNode) -> Option<LayoutNode> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
dedupe(root, &mut seen)
|
||||
}
|
||||
fn dedupe(node: LayoutNode, seen: &mut std::collections::HashSet<SurfaceId>) -> Option<LayoutNode> {
|
||||
match node {
|
||||
LayoutNode::Leaf { surface_id } => {
|
||||
if seen.insert(surface_id.clone()) { Some(LayoutNode::Leaf { surface_id }) } else { None }
|
||||
}
|
||||
LayoutNode::Split { orient, children, .. } => {
|
||||
let kept: Vec<LayoutNode> = children.into_iter().filter_map(|c| dedupe(c, seen)).collect();
|
||||
match kept.len() {
|
||||
0 => None,
|
||||
1 => Some(kept.into_iter().next().unwrap()),
|
||||
n => Some(LayoutNode::Split { orient, ratios: even(n), children: kept }),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set ratios on the split node addressed by `path` (child indices from root).
|
||||
/// Normalizes to sum 1.0 and clamps each to >= MIN_RATIO. Returns false if the
|
||||
/// path is invalid or the length does not match the node's child count.
|
||||
@@ -247,6 +273,27 @@ mod tests {
|
||||
assert_eq!(leaves(&after), vec![sid("s_2"), sid("s_1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupe_removes_duplicate_leaf_keeping_first() {
|
||||
// s_1 appears twice (the production corruption): heal to one occurrence.
|
||||
let root = LayoutNode::Split {
|
||||
orient: Orient::H, ratios: vec![1.0/3.0; 3],
|
||||
children: vec![LayoutNode::leaf(sid("s_0")), LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_1"))],
|
||||
};
|
||||
let healed = dedupe_leaves(root).unwrap();
|
||||
assert_eq!(leaves(&healed), vec![sid("s_0"), sid("s_1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupe_clean_tree_is_unchanged() {
|
||||
let root = LayoutNode::Split {
|
||||
orient: Orient::H, ratios: vec![0.5, 0.5],
|
||||
children: vec![LayoutNode::leaf(sid("s_0")), LayoutNode::leaf(sid("s_1"))],
|
||||
};
|
||||
let out = dedupe_leaves(root.clone()).unwrap();
|
||||
assert_eq!(leaves(&out), vec![sid("s_0"), sid("s_1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_onto_self_is_noop() {
|
||||
let root = LayoutNode::leaf(sid("s_1"));
|
||||
|
||||
@@ -8,6 +8,19 @@ pub struct ConfigView {
|
||||
pub font_size: u16,
|
||||
pub theme: 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)]
|
||||
@@ -18,6 +31,8 @@ mod tests {
|
||||
let c = ConfigView {
|
||||
default_shell: "/bin/zsh".into(), font_family: "JetBrains Mono".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();
|
||||
assert_eq!(back, c);
|
||||
|
||||
@@ -151,6 +151,12 @@ pub enum Cmd {
|
||||
theme: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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") {
|
||||
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 {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ pub struct AppearanceConfig {
|
||||
pub theme: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub accent: Option<String>,
|
||||
/// Background-theme name (Warp-style full-window fill). "none" = solid.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<String>,
|
||||
/// Absolute path to a custom background image (used when background == "custom").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background_image: Option<String>,
|
||||
}
|
||||
|
||||
/// Built-in resume args for known agents, used when config has no override.
|
||||
@@ -51,6 +57,10 @@ pub struct Config {
|
||||
/// How often (seconds) the daemon dumps changed grids to disk.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub snapshot_interval_secs: Option<u64>,
|
||||
/// Log/notify shell-command status (OSC 133 / fallback) in plain panels.
|
||||
/// Off by default — only agent activity (claude/codex/… hooks) is logged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_shell_commands: Option<bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -62,9 +72,17 @@ impl Config {
|
||||
font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
|
||||
theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
|
||||
accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()),
|
||||
background: self.appearance.background.clone().unwrap_or_else(|| "none".into()),
|
||||
background_image: self.appearance.background_image.clone().unwrap_or_default(),
|
||||
log_shell_commands: self.log_shell_commands(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether shell-command status events are logged (default false).
|
||||
pub fn log_shell_commands(&self) -> bool {
|
||||
self.log_shell_commands.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Shell for a plain panel using THIS in-memory config
|
||||
/// (env -> config -> passwd -> $SHELL -> /bin/sh).
|
||||
pub fn resolved_shell(&self) -> String {
|
||||
|
||||
@@ -18,35 +18,39 @@ fn dir_for(home: &PathBuf, sid: &SurfaceId) -> PathBuf {
|
||||
home.join(".spacesh").join("hooks").join(&sid.0)
|
||||
}
|
||||
|
||||
/// Build the settings.json contents wiring Stop/Notification/UserPromptSubmit
|
||||
/// to `spacesh notify`. `spacesh_bin` is the absolute path to the CLI.
|
||||
/// Our Stop/Notification/UserPromptSubmit hooks as a JSON value (the `hooks` object).
|
||||
fn our_hooks(spacesh_bin: &str) -> serde_json::Value {
|
||||
let entry = |state: &str| serde_json::json!({
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": format!("{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}")
|
||||
}]
|
||||
});
|
||||
serde_json::json!({
|
||||
"Stop": [entry("done")],
|
||||
"Notification": [entry("wait")],
|
||||
"UserPromptSubmit": [entry("work")],
|
||||
})
|
||||
}
|
||||
|
||||
/// Our hooks as a standalone settings JSON string. Passed to `claude` via the
|
||||
/// `--settings` flag so they layer ON TOP of the user's real config WITHOUT
|
||||
/// relocating CLAUDE_CONFIG_DIR — which is the whole point: claude only reads the
|
||||
/// macOS Keychain login (and onboarding state) for its DEFAULT config dir, so any
|
||||
/// override left the agent "Not logged in". `--settings` keeps the default dir.
|
||||
pub fn settings_json(spacesh_bin: &str) -> String {
|
||||
let line = |state: &str| {
|
||||
format!(
|
||||
"{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}\"}}]}}"
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"{{\"hooks\":{{\"Stop\":[{}],\"Notification\":[{}],\"UserPromptSubmit\":[{}]}}}}",
|
||||
line("done"), line("wait"), line("work")
|
||||
)
|
||||
serde_json::to_string(&serde_json::json!({ "hooks": our_hooks(spacesh_bin) }))
|
||||
.unwrap_or_else(|_| "{\"hooks\":{}}".to_string())
|
||||
}
|
||||
|
||||
/// Prepare the per-surface hook config; return env pairs to merge into the spawn.
|
||||
/// Best-effort: on any I/O error returns an empty vec (spawn proceeds without hooks).
|
||||
pub fn prepare(sid: &SurfaceId, spacesh_bin: &str) -> Vec<(String, String)> {
|
||||
let Some(home) = dirs::home_dir() else { return vec![] };
|
||||
let dir = dir_for(&home, sid);
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return vec![];
|
||||
}
|
||||
if std::fs::write(dir.join("settings.json"), settings_json(spacesh_bin)).is_err() {
|
||||
return vec![];
|
||||
}
|
||||
vec![("CLAUDE_CONFIG_DIR".to_string(), dir.to_string_lossy().to_string())]
|
||||
/// Extra CLI args injecting our notify hooks into a spawned `claude`, leaving its
|
||||
/// default config dir (Keychain auth + onboarding) untouched.
|
||||
pub fn claude_settings_args(spacesh_bin: &str) -> Vec<String> {
|
||||
vec!["--settings".to_string(), settings_json(spacesh_bin)]
|
||||
}
|
||||
|
||||
/// Remove the per-surface hook dir (best-effort) on close.
|
||||
/// Remove the legacy per-surface hook dir (best-effort) on close. No longer
|
||||
/// written, but cleans up dirs left by older builds that used CLAUDE_CONFIG_DIR.
|
||||
pub fn cleanup(sid: &SurfaceId) {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let _ = std::fs::remove_dir_all(dir_for(&home, sid));
|
||||
@@ -129,15 +133,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_writes_config_and_cleanup_removes_it() {
|
||||
let sid = SurfaceId(format!("s_test_{}", std::process::id()));
|
||||
let env = prepare(&sid, "/abs/spacesh");
|
||||
assert_eq!(env.len(), 1);
|
||||
assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR");
|
||||
let dir = std::path::PathBuf::from(&env[0].1);
|
||||
assert!(dir.join("settings.json").exists());
|
||||
cleanup(&sid);
|
||||
assert!(!dir.exists());
|
||||
fn claude_settings_args_pass_hooks_via_settings_flag() {
|
||||
let args = claude_settings_args("/abs/spacesh");
|
||||
assert_eq!(args.len(), 2);
|
||||
assert_eq!(args[0], "--settings");
|
||||
// The second arg is valid JSON carrying all three hook events.
|
||||
let v: serde_json::Value = serde_json::from_str(&args[1]).unwrap();
|
||||
assert!(v["hooks"]["Stop"].is_array());
|
||||
assert!(args[1].contains("/abs/spacesh notify --surface $SPACESH_SURFACE_ID --state done"));
|
||||
assert!(args[1].contains("--state wait"));
|
||||
assert!(args[1].contains("--state work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,6 +23,12 @@ pub struct Registry {
|
||||
states: HashMap<SurfaceId, SurfaceState>,
|
||||
}
|
||||
|
||||
/// Parse the hex numeric suffix of an id (`"s_1f"` → `0x1f`). None if malformed.
|
||||
/// All ids are minted as `format!("{prefix}_{n:x}")`, so the suffix is hex.
|
||||
fn id_num(id: &str) -> Option<u64> {
|
||||
id.rsplit_once('_').and_then(|(_, hex)| u64::from_str_radix(hex, 16).ok())
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -193,8 +199,29 @@ impl Registry {
|
||||
self.by_path.clear();
|
||||
self.live.clear();
|
||||
self.states.clear();
|
||||
|
||||
// Advance the id counter past every restored id. The in-memory counter
|
||||
// resets to 0 on each daemon start; without this reseed, after a restart
|
||||
// `new_surface_id()` re-mints ids that already exist — producing duplicate
|
||||
// leaves in a workspace tree (same panel rendered twice, focus/search/
|
||||
// output routing keyed by surface id all break) and cross-workspace id
|
||||
// collisions.
|
||||
let mut max_id = 0u64;
|
||||
for gid in self.groups.keys() {
|
||||
if let Some(n) = id_num(&gid.0) { max_id = max_id.max(n + 1); }
|
||||
}
|
||||
for w in &state.workspaces {
|
||||
if let Some(n) = id_num(&w.id.0) { max_id = max_id.max(n + 1); }
|
||||
for sid in w.surfaces.keys() {
|
||||
if let Some(n) = id_num(&sid.0) { max_id = max_id.max(n + 1); }
|
||||
}
|
||||
}
|
||||
self.counter.store(max_id, Ordering::Relaxed);
|
||||
|
||||
for w in state.workspaces {
|
||||
let mut w = w;
|
||||
// Heal a tree already corrupted by a duplicate leaf (pre-fix state).
|
||||
w.layout = w.layout.take().and_then(spacesh_core::ops::dedupe_leaves);
|
||||
if let Some(z) = &w.zoomed {
|
||||
if !w.surfaces.contains_key(z) { w.zoomed = None; }
|
||||
}
|
||||
@@ -252,6 +279,52 @@ mod tests {
|
||||
assert_eq!(w.layout, Some(LN::leaf(s1))); // split collapsed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_advances_counter_past_existing_ids() {
|
||||
// After a daemon restart the counter must not re-mint a restored id.
|
||||
let mut r = Registry::new();
|
||||
let mut surfaces = HashMap::new();
|
||||
surfaces.insert(SurfaceId("s_5".into()), spec());
|
||||
let st = PersistState {
|
||||
version: 1, groups: vec![],
|
||||
workspaces: vec![Workspace {
|
||||
id: WorkspaceId("w_2".into()), path: "/p".into(), name: "p".into(),
|
||||
group_id: None, order: 0, unread: false, pinned: false,
|
||||
layout: Some(LN::leaf(SurfaceId("s_5".into()))), zoomed: None, surfaces,
|
||||
}],
|
||||
};
|
||||
r.restore(st);
|
||||
// max restored id is s_5 (hex 5) → next minted must be s_6, no collision.
|
||||
assert_eq!(r.new_surface_id(), SurfaceId("s_6".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_heals_duplicate_leaf() {
|
||||
// A persisted tree with s_1 twice (the production corruption) heals to one.
|
||||
let mut r = Registry::new();
|
||||
let mut surfaces = HashMap::new();
|
||||
surfaces.insert(SurfaceId("s_0".into()), spec());
|
||||
surfaces.insert(SurfaceId("s_1".into()), spec());
|
||||
let tree = LN::Split {
|
||||
orient: Orient::H, ratios: vec![1.0 / 3.0; 3],
|
||||
children: vec![LN::leaf(SurfaceId("s_0".into())), LN::leaf(SurfaceId("s_1".into())), LN::leaf(SurfaceId("s_1".into()))],
|
||||
};
|
||||
let st = PersistState {
|
||||
version: 1, groups: vec![],
|
||||
workspaces: vec![Workspace {
|
||||
id: WorkspaceId("w_0".into()), path: "/p".into(), name: "p".into(),
|
||||
group_id: None, order: 0, unread: false, pinned: false,
|
||||
layout: Some(tree), zoomed: None, surfaces,
|
||||
}],
|
||||
};
|
||||
r.restore(st);
|
||||
let w = r.workspace(&WorkspaceId("w_0".into())).unwrap();
|
||||
assert_eq!(
|
||||
spacesh_core::ops::leaves(w.layout.as_ref().unwrap()),
|
||||
vec![SurfaceId("s_0".into()), SurfaceId("s_1".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_round_trips_through_persist_state() {
|
||||
let mut r = Registry::new();
|
||||
|
||||
@@ -189,8 +189,13 @@ async fn router(
|
||||
if reg.is_running(&surface_id) {
|
||||
reg.set_state(&surface_id, state);
|
||||
broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state }));
|
||||
if let Some(kind) = kind_for_state(state) {
|
||||
record_event(®, &mut event_log, &event_persister, &clients, &surface_id, kind);
|
||||
// StateDetected is the shell path (OSC 133 / fallback scanner). Off by
|
||||
// default it stays a live status ring only — no log entry, no notification.
|
||||
// Agent activity flows through Cmd::SetState (hooks) and is always logged.
|
||||
if config.log_shell_commands() {
|
||||
if let Some(kind) = kind_for_state(state) {
|
||||
record_event(®, &mut event_log, &event_persister, &clients, &surface_id, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,9 +280,10 @@ fn err(id: u64, code: &str, msg: &str) -> Envelope {
|
||||
/// and whether a deterministic hook source is active.
|
||||
fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (Vec<(String, String)>, bool) {
|
||||
let (mut env, active) = if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
|
||||
let env = crate::hooks::prepare(sid, &crate::hooks::spacesh_bin());
|
||||
let active = !env.is_empty();
|
||||
(env, active)
|
||||
// Hooks are injected as `--settings` CLI args at spawn (see spawn_from_spec),
|
||||
// not via env — that keeps claude on its default config dir so Keychain login
|
||||
// and onboarding survive. The agent still has a deterministic hook source.
|
||||
(vec![], true)
|
||||
} else if crate::hooks::is_zsh(&spec.command) {
|
||||
(crate::hooks::shell_env(sid), false)
|
||||
} else {
|
||||
@@ -756,7 +762,7 @@ async fn handle_request(
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::SetConfig { default_shell, font_family, font_size, theme, accent } => {
|
||||
Cmd::SetConfig { default_shell, font_family, font_size, theme, accent, background, background_image, log_shell_commands } => {
|
||||
if let Some(v) = &theme {
|
||||
if v != "dark" && v != "light" { let _ = out.send(err(id, "BAD_CONFIG", "theme")).await; return; }
|
||||
}
|
||||
@@ -764,12 +770,19 @@ async fn handle_request(
|
||||
const ACCENTS: [&str; 5] = ["blue", "teal", "purple", "green", "orange"];
|
||||
if !ACCENTS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "accent")).await; return; }
|
||||
}
|
||||
if let Some(v) = &background {
|
||||
const BACKGROUNDS: [&str; 8] = ["none", "cyberwave", "phenomenon", "dracula", "aurora", "ember", "referred", "custom"];
|
||||
if !BACKGROUNDS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "background")).await; return; }
|
||||
}
|
||||
let mut next = config.clone();
|
||||
if let Some(v) = default_shell { next.default_shell = if v.is_empty() { None } else { Some(v) }; }
|
||||
if let Some(v) = font_family { next.terminal.font_family = if v.is_empty() { None } else { Some(v) }; }
|
||||
if let Some(v) = font_size { next.terminal.font_size = Some(v.clamp(10, 20)); }
|
||||
if let Some(v) = theme { next.appearance.theme = Some(v); }
|
||||
if let Some(v) = accent { next.appearance.accent = Some(v); }
|
||||
if let Some(v) = background { next.appearance.background = if v == "none" { None } else { Some(v) }; }
|
||||
if let Some(v) = background_image { next.appearance.background_image = if v.is_empty() { None } else { Some(v) }; }
|
||||
if let Some(v) = log_shell_commands { next.log_shell_commands = Some(v); }
|
||||
let to_save = next.clone();
|
||||
match tokio::task::spawn_blocking(move || to_save.save()).await {
|
||||
Ok(Ok(())) => {
|
||||
@@ -1244,8 +1257,7 @@ mod tests {
|
||||
}).await;
|
||||
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
|
||||
|
||||
// Observer connection: receives all broadcast events (the detected-state path
|
||||
// flows through ServerMsg::StateDetected → record_event → Evt::Event).
|
||||
// Observer connection: receives all broadcast events.
|
||||
let mut observer = UnixStream::connect(&sock).await.unwrap();
|
||||
|
||||
// Drive the PTY output by attaching the control connection.
|
||||
@@ -1253,21 +1265,27 @@ mod tests {
|
||||
surface_id: spacesh_proto::SurfaceId(sid.clone()),
|
||||
}).await;
|
||||
|
||||
// Expect an Evt::Event (kind=done) for this surface from the OSC 133 Done detection.
|
||||
let mut found = None;
|
||||
// Shell-command status (OSC 133) updates the live status ring (Evt::State) but is
|
||||
// NOT logged by default — log_shell_commands defaults to false, so no Evt::Event
|
||||
// is recorded for plain shell panels. (Agent activity flows through Cmd::SetState.)
|
||||
let mut saw_state_done = false;
|
||||
let mut saw_event = false;
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(3);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if let Ok(Ok(Some(env))) =
|
||||
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await {
|
||||
if let Envelope::Evt(Evt::Event { record }) = env {
|
||||
if record.surface_id.0 == sid { found = Some(record); break; }
|
||||
match env {
|
||||
Envelope::Evt(Evt::State { surface_id, state })
|
||||
if surface_id.0 == sid && state == spacesh_proto::status::SurfaceState::Done => { saw_state_done = true; }
|
||||
// Exit (process end) is always logged; only command-status events are gated.
|
||||
Envelope::Evt(Evt::Event { record })
|
||||
if record.surface_id.0 == sid && record.kind != spacesh_proto::event::EventKind::Exit => { saw_event = true; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let rec = found.expect("expected an Evt::Event from the OSC 133 detected state");
|
||||
assert_eq!(rec.kind, spacesh_proto::event::EventKind::Done);
|
||||
assert!(!rec.read);
|
||||
assert_eq!(rec.workspace_id.0, ws);
|
||||
assert!(saw_state_done, "expected an Evt::State(done) from the OSC 133 detection");
|
||||
assert!(!saw_event, "shell-command events must not be logged when log_shell_commands is off");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -17,8 +17,11 @@ pub enum SnapshotMsg {
|
||||
Remove(SurfaceId),
|
||||
}
|
||||
|
||||
/// A no-op store for tests and contexts that do not persist snapshots.
|
||||
/// A no-op store for tests that do not persist snapshots. Test-only — gated so
|
||||
/// release builds don't warn about an unconstructed struct.
|
||||
#[cfg(test)]
|
||||
pub struct NullSnapshotStore;
|
||||
#[cfg(test)]
|
||||
impl SnapshotStore for NullSnapshotStore {
|
||||
fn save(&self, _sid: &SurfaceId, _snap: &Snapshot) {}
|
||||
fn load(&self, _sid: &SurfaceId) -> Option<Snapshot> { None }
|
||||
|
||||
@@ -29,9 +29,16 @@ pub fn spawn_from_spec(
|
||||
) -> std::io::Result<SurfaceHandle> {
|
||||
let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())];
|
||||
env.extend(extra_env);
|
||||
// For a Claude agent, inject our notify hooks via `--settings` so they layer on
|
||||
// top of the user's real config without relocating CLAUDE_CONFIG_DIR (which would
|
||||
// hide the Keychain login). Surface id reaches the hook through SPACESH_SURFACE_ID.
|
||||
let mut args = spec.args.clone();
|
||||
if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
|
||||
args.extend(crate::hooks::claude_settings_args(&crate::hooks::spacesh_bin()));
|
||||
}
|
||||
let spawn_spec = SpawnSpec {
|
||||
command: spec.command.clone(),
|
||||
args: spec.args.clone(),
|
||||
args,
|
||||
cwd: std::path::PathBuf::from(&spec.cwd),
|
||||
cols: spec.cols,
|
||||
rows: spec.rows,
|
||||
|
||||
@@ -25,6 +25,18 @@ mkdir -p $SSH_REMOTE_DIR/download
|
||||
server, then `docker compose pull && up -d`.
|
||||
- **DMG** → built on macOS, uploaded by hand: `make deploy-dmg`
|
||||
(sets the stable `download/spacesh.dmg`). Tauri can't build a macOS bundle in CI.
|
||||
The same command also publishes a **versioned** copy to the Gitea package
|
||||
registry (`Packages` tab) when `GITEA_TOKEN` is set:
|
||||
|
||||
```bash
|
||||
GITEA_TOKEN=<token-with-package:write> make deploy-dmg # server + Gitea Packages
|
||||
make publish-dmg GITEA_TOKEN=<token> # build + Gitea Packages only
|
||||
```
|
||||
|
||||
Published at `{GITEA_URL}/api/packages/{GITEA_OWNER}/generic/spacesh/<version>/spacesh-<version>.dmg`
|
||||
(override `GITEA_URL`/`GITEA_OWNER`/`GITEA_PKG` if needed). Version comes from
|
||||
`tauri.conf.json`, bumped on every `make dmg`. Without `GITEA_TOKEN` the publish
|
||||
step is skipped (server copy still happens).
|
||||
|
||||
## Gitea secrets required
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
services:
|
||||
landing:
|
||||
# LANDING_IMAGE is written to .env by the CI deploy job (exact registry path + tag).
|
||||
image: ${LANDING_IMAGE:-git.realmanual.ru/spacesh/spacesh-landing:latest}
|
||||
image: ${LANDING_IMAGE:-git.realmanual.ru/pub/spacesh-landing:latest}
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
+12
-11
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>spacesh — терминал-воркспейс для AI-агентов на macOS</title>
|
||||
<title>spaceshell — терминал-воркспейс для AI-агентов на macOS</title>
|
||||
<meta name="description" content="Запускай Claude Code, Codex, Gemini и shell параллельно. Фоновый демон держит сессии живыми: закрыл окно — агенты работают. Скачать для macOS.">
|
||||
<link rel="canonical" href="https://spaceshell.ru">
|
||||
<meta property="og:title" content="spacesh — терминал-воркспейс для AI-агентов">
|
||||
<meta property="og:title" content="spaceshell — терминал-воркспейс для AI-агентов">
|
||||
<meta property="og:description" content="Десяток AI-агентов параллельно. Демон держит сессии живыми — закрой окно, агенты работают.">
|
||||
<meta property="og:url" content="https://spaceshell.ru">
|
||||
<meta property="og:image" content="https://spaceshell.ru/og.png">
|
||||
@@ -812,13 +812,13 @@
|
||||
<div class="header-inner">
|
||||
<a href="/" class="logo">
|
||||
<span class="logo-icon">>_</span>
|
||||
spacesh
|
||||
spaceshell
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="#features">Возможности</a>
|
||||
<a href="#how">Как работает</a>
|
||||
<a href="#cli">CLI</a>
|
||||
<a href="https://git.realmanual.ru/realmanual/spaceshell" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener">GitHub</a>
|
||||
</nav>
|
||||
<a href="https://spaceshell.ru/download/spacesh.dmg" download class="btn btn-primary">Скачать для macOS</a>
|
||||
<button class="mobile-nav-toggle" aria-label="Меню">☰</button>
|
||||
@@ -836,7 +836,7 @@
|
||||
Гоняй десяток AI-агентов параллельно. <span class="accent">Не теряй ни одного.</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">
|
||||
spacesh держит живые сессии Claude Code, Codex, Gemini и shell в фоновом демоне.
|
||||
spaceshell держит живые сессии Claude Code, Codex, Gemini и shell в фоновом демоне.
|
||||
Закрыл окно, обновил приложение, словил краш — агенты продолжают работать.
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
@@ -860,7 +860,7 @@
|
||||
<span class="terminal-dot"></span>
|
||||
<span class="terminal-dot"></span>
|
||||
</div>
|
||||
<span class="terminal-title">spacesh — workspace</span>
|
||||
<span class="terminal-title">spaceshell — workspace</span>
|
||||
</div>
|
||||
<div class="terminal-grid">
|
||||
<div class="terminal-pane">
|
||||
@@ -928,6 +928,7 @@
|
||||
<span class="agent-tag">Codex</span>
|
||||
<span class="agent-tag">Gemini</span>
|
||||
<span class="agent-tag">opencode</span>
|
||||
<span class="agent-tag">deepseek</span>
|
||||
<span class="agent-tag">shell</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -946,7 +947,7 @@
|
||||
</div>
|
||||
<div class="solution-card">
|
||||
<p class="card-label">Решение</p>
|
||||
<h3 class="card-title">spacesh разрывает эту связь.</h3>
|
||||
<h3 class="card-title">spaceshell разрывает эту связь.</h3>
|
||||
<p class="card-text">
|
||||
Сессиями владеет фоновый демон, а не окно. Интерфейс — всего лишь вид поверх него.
|
||||
</p>
|
||||
@@ -999,7 +1000,7 @@
|
||||
<h3 class="feature-title">CLI как первый класс</h3>
|
||||
<p class="feature-text">
|
||||
spacesh status --json, focus, new-surface, notify — те же команды, что и в интерфейсе, плюс shell-completions.
|
||||
Встраивай spacesh в свои пайплайны.
|
||||
Встраивай spaceshell в свои пайплайны.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card reveal">
|
||||
@@ -1105,7 +1106,7 @@
|
||||
Скачать для macOS
|
||||
</a>
|
||||
</div>
|
||||
<a href="https://git.realmanual.ru/realmanual/spaceshell" target="_blank" rel="noopener" class="cta-github">
|
||||
<a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener" class="cta-github">
|
||||
Исходники на GitHub →
|
||||
</a>
|
||||
</div>
|
||||
@@ -1117,10 +1118,10 @@
|
||||
<div class="footer-inner">
|
||||
<div class="footer-left">
|
||||
<span class="footer-logo">spaceshell.ru</span>
|
||||
<span class="footer-copy">© 2026 spacesh</span>
|
||||
<span class="footer-copy">© 2026 spaceshell</span>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://git.realmanual.ru/realmanual/spaceshell" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="#">Документация</a>
|
||||
<a href="#">Лицензия</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user