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:
Generated
+14
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 }}>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user