wip: in-progress changes (grid, config, wizard, settings, pty) before session-persistence

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:28:19 +07:00
parent e37faf49d3
commit 4419f5660e
18 changed files with 365 additions and 42 deletions
+14 -1
View File
@@ -540,6 +540,17 @@ dependencies = [
"libc",
]
[[package]]
name = "core-text"
version = "22.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7"
dependencies = [
"core-foundation",
"core-graphics",
"foreign-types",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -3413,6 +3424,8 @@ version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"core-foundation",
"core-text",
"dirs 5.0.1",
"reqwest 0.12.28",
"serde",
@@ -3427,7 +3440,7 @@ dependencies = [
[[package]]
name = "spacesh-proto"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"bytes",
"serde",
+2
View File
@@ -25,3 +25,5 @@ anyhow = "1"
dirs = "5"
# rustls (no openssl) so the universal-apple-darwin cross-build stays self-contained.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
core-text = "22"
core-foundation = "0.10"
+24
View File
@@ -422,6 +422,11 @@ pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn which_agents(state: BridgeState<'_>, candidates: Vec<String>) -> Result<Value, String> {
data_of(state.request(Cmd::WhichAgents { candidates }).await.map_err(|e| e.to_string())?)
}
// ---- Update check ----
/// Where the GUI looks for the published app version. Overridable via
@@ -481,6 +486,25 @@ pub fn open_external(url: String) -> Result<(), 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.
#[tauri::command]
pub fn list_fonts() -> Vec<String> {
use std::collections::BTreeSet;
let names = core_text::font_collection::get_family_names();
let mut set: BTreeSet<String> = BTreeSet::new();
for name in names.iter() {
let s = name.to_string();
if !s.is_empty() && !s.starts_with('.') {
set.insert(s);
}
}
let mut v: Vec<String> = set.into_iter().collect();
v.sort_by_key(|s| s.to_lowercase());
v
}
// ---- Settings commands ----
#[tauri::command]
+2
View File
@@ -54,8 +54,10 @@ pub fn run() {
bridge::mark_read,
bridge::clear_events,
bridge::health,
bridge::which_agents,
bridge::check_update,
bridge::open_external,
bridge::list_fonts,
bridge::get_config,
bridge::set_config,
bridge::shutdown_daemon,
+11 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "spacesh",
"version": "0.1.0",
"version": "0.1.2",
"identifier": "xyz.spacesh.app",
"build": {
"frontendDist": "../dist",
@@ -10,8 +10,16 @@
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [{ "title": "spacesh", "width": 1100, "height": 720 }],
"security": { "csp": null }
"windows": [
{
"title": "spacesh",
"width": 1100,
"height": 720
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
+69 -8
View File
@@ -1,10 +1,74 @@
import { useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { X, Search, Check } from "lucide-react";
import { COLORS, FONT, ACCENTS } from "./theme";
import { setConfig, restartDaemon } from "./socketBridge";
import { setConfig, restartDaemon, listFonts } from "./socketBridge";
import type { ConfigView, DaemonHealth } from "./socketBridge";
const FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
// 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"];
/** Searchable font picker: type to filter, click to apply. Defaults pinned on top. */
function FontPicker({ value, onPick }: { value: string; onPick: (family: string) => void }) {
const [installed, setInstalled] = useState<string[]>([]);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => { void listFonts().then(setInstalled).catch(() => {}); }, []);
// Close on outside click.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const options = useMemo(() => {
const seen = new Set<string>();
const merged: string[] = [];
for (const f of [...DEFAULT_FONTS, ...installed]) {
const k = f.toLowerCase();
if (!seen.has(k)) { seen.add(k); merged.push(f); }
}
const q = query.trim().toLowerCase();
return q ? merged.filter((f) => f.toLowerCase().includes(q)) : merged;
}, [installed, query]);
return (
<div ref={boxRef} style={{ position: "relative", marginBottom: 10 }}>
<div style={{ position: "relative" }}>
<Search size={14} style={{ position: "absolute", left: 9, top: "50%", transform: "translateY(-50%)", color: COLORS.textMuted }} />
<input
value={open ? query : value}
placeholder={value}
onFocus={() => { setOpen(true); setQuery(""); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
style={{ width: "100%", padding: "8px 8px 8px 30px", background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${open ? COLORS.accent : COLORS.borderStrong}`, borderRadius: 8, fontFamily: FONT.ui }}
/>
</div>
{open && (
<div style={{ position: "absolute", top: "calc(100% + 4px)", left: 0, right: 0, zIndex: 10, maxHeight: 240, overflowY: "auto",
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8, boxShadow: "0 8px 24px rgba(0,0,0,0.4)" }}>
{options.length === 0 && <div style={{ padding: 10, fontSize: 12, color: COLORS.textMuted }}>Ничего не найдено</div>}
{options.map((f) => {
const isDefault = DEFAULT_FONTS.some((d) => d.toLowerCase() === f.toLowerCase());
return (
<button key={f} onClick={() => { onPick(f); setOpen(false); }}
style={{ display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left", padding: "7px 10px",
background: f === value ? COLORS.bgElevated : "transparent", border: "none", color: COLORS.textPrimary,
fontFamily: `'${f}', ${FONT.mono}`, fontSize: 13 }}>
<Check size={13} style={{ opacity: f === value ? 1 : 0, color: COLORS.accent, flex: "0 0 auto" }} />
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{f}</span>
{isDefault && <span style={{ fontFamily: FONT.ui, fontSize: 10, color: COLORS.textMuted }}>default</span>}
</button>
);
})}
</div>
)}
</div>
);
}
export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) {
const ref = useRef<HTMLDivElement>(null);
@@ -30,10 +94,7 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
</div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Terminal font</div>
<select value={config.font_family} onChange={(e) => void setConfig({ font_family: e.target.value })}
style={{ width: "100%", padding: 8, marginBottom: 10, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }}>
{FONTS.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
<FontPicker value={config.font_family} onPick={(f) => void setConfig({ font_family: f })} />
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18 }}>
<span style={{ fontSize: 12, color: COLORS.textSecondary }}>Size {sizeLocal}</span>
<input type="range" min={10} max={20} value={sizeLocal}
+15 -2
View File
@@ -9,6 +9,13 @@ import { registerSearch, unregisterSearch } from "./searchRegistry";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
// 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'";
const fontStack = (family: string | null) =>
family ? `'${family}', ${NERD_FALLBACK}, monospace`
: `'JetBrains Mono Variable', 'JetBrains Mono', ${NERD_FALLBACK}, monospace`;
function xtermTheme(p: Record<string, string>) {
return {
background: p["bg-panel"],
@@ -30,7 +37,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
// call registerMarker/registerDecoration (proposed API). Without it findNext
// throws and the scrollback search counter never updates.
const term = new Terminal({
fontFamily: font ? `'${font.family}', monospace` : "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
fontFamily: fontStack(font?.family ?? null),
fontSize: font?.size ?? 13,
convertEol: false,
scrollback: 10000,
@@ -81,6 +88,12 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
let disposed = false;
// The Nerd Font fallback may finish loading after the first paint; once it's
// ready, drop the WebGL glyph atlas so cached blank cells re-rasterize with icons.
void document.fonts.load("16px 'Symbols Nerd Font Mono'").then(() => {
if (!disposed) webglRef.current?.clearTextureAtlas();
}).catch(() => {});
// Attach: fresh xterm instance, write snapshot, then stream live output.
void attachSurface(surfaceId, (bytes) => {
if (!disposed) term.write(decoder.decode(bytes));
@@ -112,7 +125,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
const t = termRef.current;
if (!t) return;
if (font) {
t.options.fontFamily = `'${font.family}', monospace`;
t.options.fontFamily = fontStack(font.family);
t.options.fontSize = font.size;
// The WebGL renderer caches rasterized glyphs in a texture atlas keyed by
// the old font/size; without clearing it the grid keeps rendering stale
+33 -9
View File
@@ -1,16 +1,22 @@
import { useEffect, useRef, useState } from "react";
import { PresetPicker, PRESETS } from "./PresetPicker";
import { openWorkspace, applyPreset } from "./socketBridge";
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…";
export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) {
const [path, setPath] = useState(".");
const [preset, setPreset] = useState("2x2");
const [agents, setAgents] = useState<string[]>([]);
const [customCmds, setCustomCmds] = useState<string[]>([]);
const [installed, setInstalled] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
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", "claude", "codex", "gemini"];
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).
@@ -19,6 +25,9 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
pathRef.current?.select();
}, []);
// Only offer agents the user actually has installed.
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
async function create() {
if (busy) return;
setBusy(true);
@@ -27,7 +36,12 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
const ws = await openWorkspace(path);
const slotSpecs = Array.from({ length: slots }, (_, i) => {
const a = agents[i] ?? "shell";
return a === "shell" ? {} : { command: a };
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 };
});
await applyPreset(ws, preset, slotSpecs);
onDone(ws);
@@ -61,12 +75,22 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
<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) => (
<select key={i} value={agents[i] ?? "shell"} 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>)}
</select>
))}
{Array.from({ length: slots }, (_, i) => {
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>)}
</select>
{val === CUSTOM && (
<input value={customCmds[i] ?? ""} placeholder="e.g. npm run dev"
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>
{error && <div style={{ margin: "0 0 14px", padding: "8px 10px", background: "#3A1418", border: "1px solid #6B2230", borderRadius: 8, fontSize: 12, color: "#FF9AA6" }}>{error}</div>}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
+10
View File
@@ -199,6 +199,16 @@ export async function openExternal(url: string): Promise<void> {
await invoke("open_external", { url });
}
export async function listFonts(): Promise<string[]> {
return await invoke<string[]>("list_fonts");
}
/** 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 });
return data.available;
}
export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> {
await invoke("set_zoom", { workspaceId, surfaceId });
}
+9
View File
@@ -1,3 +1,12 @@
/* Nerd Font symbols (icons, powerline, devicons) used as a fallback in the
terminal so glyphs in the Private Use Area render instead of blank boxes.
Base monospace font is untouched; this only fills missing glyphs. */
@font-face {
font-family: "Symbols Nerd Font Mono";
src: url("./assets/SymbolsNerdFontMono-Regular.ttf") format("truetype");
font-display: swap;
}
:root {
color-scheme: dark;
}