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
+5
-5
@@ -869,7 +869,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-cli"
|
name = "spacesh-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -881,7 +881,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-core"
|
name = "spacesh-core"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -890,7 +890,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-proto"
|
name = "spacesh-proto"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -902,7 +902,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-pty"
|
name = "spacesh-pty"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -912,7 +912,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spaceshd"
|
name = "spaceshd"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ members = [
|
|||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ targets: ## add rust targets for the universal build
|
|||||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
|
||||||
.PHONY: bump
|
.PHONY: bump
|
||||||
bump: ## increment the patch version in tauri.conf.json (single source of truth)
|
bump: ## increment the patch version for BOTH the GUI (tauri.conf.json) and the daemon (workspace Cargo.toml)
|
||||||
@node -e "const f='$(APP_DIR)/src-tauri/tauri.conf.json';const fs=require('fs');const j=JSON.parse(fs.readFileSync(f));const p=j.version.split('.').map(Number);p[2]=(p[2]||0)+1;j.version=p.join('.');fs.writeFileSync(f, JSON.stringify(j,null,2)+'\n');console.log('version → '+j.version)"
|
@node scripts/bump_version.mjs
|
||||||
|
|
||||||
.PHONY: dmg
|
.PHONY: dmg
|
||||||
dmg: bump targets ## bump version + build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
|
dmg: bump targets ## bump version + build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
|
||||||
|
|||||||
Generated
+14
-1
@@ -540,6 +540,17 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -3413,6 +3424,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"core-foundation",
|
||||||
|
"core-text",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3427,7 +3440,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-proto"
|
name = "spacesh-proto"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -25,3 +25,5 @@ anyhow = "1"
|
|||||||
dirs = "5"
|
dirs = "5"
|
||||||
# rustls (no openssl) so the universal-apple-darwin cross-build stays self-contained.
|
# rustls (no openssl) so the universal-apple-darwin cross-build stays self-contained.
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
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())?)
|
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 ----
|
// ---- Update check ----
|
||||||
|
|
||||||
/// Where the GUI looks for the published app version. Overridable via
|
/// Where the GUI looks for the published app version. Overridable via
|
||||||
@@ -481,6 +486,25 @@ pub fn open_external(url: String) -> Result<(), String> {
|
|||||||
Ok(())
|
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 ----
|
// ---- Settings commands ----
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ pub fn run() {
|
|||||||
bridge::mark_read,
|
bridge::mark_read,
|
||||||
bridge::clear_events,
|
bridge::clear_events,
|
||||||
bridge::health,
|
bridge::health,
|
||||||
|
bridge::which_agents,
|
||||||
bridge::check_update,
|
bridge::check_update,
|
||||||
bridge::open_external,
|
bridge::open_external,
|
||||||
|
bridge::list_fonts,
|
||||||
bridge::get_config,
|
bridge::get_config,
|
||||||
bridge::set_config,
|
bridge::set_config,
|
||||||
bridge::shutdown_daemon,
|
bridge::shutdown_daemon,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "spacesh",
|
"productName": "spacesh",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"identifier": "xyz.spacesh.app",
|
"identifier": "xyz.spacesh.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -10,8 +10,16 @@
|
|||||||
"beforeBuildCommand": "npm run build"
|
"beforeBuildCommand": "npm run build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [{ "title": "spacesh", "width": 1100, "height": 720 }],
|
"windows": [
|
||||||
"security": { "csp": null }
|
{
|
||||||
|
"title": "spacesh",
|
||||||
|
"width": 1100,
|
||||||
|
"height": 720
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
|||||||
+69
-8
@@ -1,10 +1,74 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X, Search, Check } from "lucide-react";
|
||||||
import { COLORS, FONT, ACCENTS } from "./theme";
|
import { COLORS, FONT, ACCENTS } from "./theme";
|
||||||
import { setConfig, restartDaemon } from "./socketBridge";
|
import { setConfig, restartDaemon, listFonts } from "./socketBridge";
|
||||||
import type { ConfigView, DaemonHealth } 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 }) {
|
export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -30,10 +94,7 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Terminal font</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 })}
|
<FontPicker value={config.font_family} onPick={(f) => void setConfig({ font_family: f })} />
|
||||||
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>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18 }}>
|
||||||
<span style={{ fontSize: 12, color: COLORS.textSecondary }}>Size {sizeLocal}</span>
|
<span style={{ fontSize: 12, color: COLORS.textSecondary }}>Size {sizeLocal}</span>
|
||||||
<input type="range" min={10} max={20} value={sizeLocal}
|
<input type="range" min={10} max={20} value={sizeLocal}
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import { registerSearch, unregisterSearch } from "./searchRegistry";
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const encoder = new TextEncoder();
|
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>) {
|
function xtermTheme(p: Record<string, string>) {
|
||||||
return {
|
return {
|
||||||
background: p["bg-panel"],
|
background: p["bg-panel"],
|
||||||
@@ -30,7 +37,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
|||||||
// call registerMarker/registerDecoration (proposed API). Without it findNext
|
// call registerMarker/registerDecoration (proposed API). Without it findNext
|
||||||
// throws and the scrollback search counter never updates.
|
// throws and the scrollback search counter never updates.
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
fontFamily: font ? `'${font.family}', monospace` : "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
|
fontFamily: fontStack(font?.family ?? null),
|
||||||
fontSize: font?.size ?? 13,
|
fontSize: font?.size ?? 13,
|
||||||
convertEol: false,
|
convertEol: false,
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
@@ -81,6 +88,12 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
|||||||
|
|
||||||
let disposed = false;
|
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.
|
// Attach: fresh xterm instance, write snapshot, then stream live output.
|
||||||
void attachSurface(surfaceId, (bytes) => {
|
void attachSurface(surfaceId, (bytes) => {
|
||||||
if (!disposed) term.write(decoder.decode(bytes));
|
if (!disposed) term.write(decoder.decode(bytes));
|
||||||
@@ -112,7 +125,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
|||||||
const t = termRef.current;
|
const t = termRef.current;
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
if (font) {
|
if (font) {
|
||||||
t.options.fontFamily = `'${font.family}', monospace`;
|
t.options.fontFamily = fontStack(font.family);
|
||||||
t.options.fontSize = font.size;
|
t.options.fontSize = font.size;
|
||||||
// The WebGL renderer caches rasterized glyphs in a texture atlas keyed by
|
// The WebGL renderer caches rasterized glyphs in a texture atlas keyed by
|
||||||
// the old font/size; without clearing it the grid keeps rendering stale
|
// the old font/size; without clearing it the grid keeps rendering stale
|
||||||
|
|||||||
+30
-6
@@ -1,16 +1,22 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { PresetPicker, PRESETS } from "./PresetPicker";
|
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 }) {
|
export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) {
|
||||||
const [path, setPath] = useState(".");
|
const [path, setPath] = useState(".");
|
||||||
const [preset, setPreset] = useState("2x2");
|
const [preset, setPreset] = useState("2x2");
|
||||||
const [agents, setAgents] = useState<string[]>([]);
|
const [agents, setAgents] = useState<string[]>([]);
|
||||||
|
const [customCmds, setCustomCmds] = useState<string[]>([]);
|
||||||
|
const [installed, setInstalled] = useState<string[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const pathRef = useRef<HTMLInputElement>(null);
|
const pathRef = useRef<HTMLInputElement>(null);
|
||||||
const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1;
|
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
|
// 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).
|
// (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();
|
pathRef.current?.select();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Only offer agents the user actually has installed.
|
||||||
|
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -27,7 +36,12 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
|
|||||||
const ws = await openWorkspace(path);
|
const ws = await openWorkspace(path);
|
||||||
const slotSpecs = Array.from({ length: slots }, (_, i) => {
|
const slotSpecs = Array.from({ length: slots }, (_, i) => {
|
||||||
const a = agents[i] ?? "shell";
|
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);
|
await applyPreset(ws, preset, slotSpecs);
|
||||||
onDone(ws);
|
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>
|
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
|
||||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
|
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}>
|
||||||
{Array.from({ length: slots }, (_, i) => (
|
{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; })}
|
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 }}>
|
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}>{c}</option>)}
|
||||||
</select>
|
</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>
|
</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>}
|
{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 }}>
|
<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 });
|
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> {
|
export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> {
|
||||||
await invoke("set_zoom", { workspaceId, surfaceId });
|
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 {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use alacritty_terminal::event::VoidListener;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use alacritty_terminal::event::{Event, EventListener};
|
||||||
use alacritty_terminal::grid::Dimensions;
|
use alacritty_terminal::grid::Dimensions;
|
||||||
use alacritty_terminal::index::{Column, Line, Point};
|
use alacritty_terminal::index::{Column, Line, Point};
|
||||||
use alacritty_terminal::term::{Config, Term};
|
use alacritty_terminal::term::{Config, Term};
|
||||||
@@ -23,24 +25,55 @@ impl Dimensions for GridSize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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").
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct ReplyCollector {
|
||||||
|
buf: Arc<Mutex<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Owns an alacritty terminal model and feeds raw PTY bytes into it.
|
/// Owns an alacritty terminal model and feeds raw PTY bytes into it.
|
||||||
pub struct GridSurface {
|
pub struct GridSurface {
|
||||||
term: Term<VoidListener>,
|
term: Term<ReplyCollector>,
|
||||||
parser: Processor,
|
parser: Processor,
|
||||||
size: GridSize,
|
size: GridSize,
|
||||||
|
replies: ReplyCollector,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GridSurface {
|
impl GridSurface {
|
||||||
pub fn new(cols: u16, rows: u16) -> Self {
|
pub fn new(cols: u16, rows: u16) -> Self {
|
||||||
let size = GridSize { cols: cols as usize, lines: rows as usize };
|
let size = GridSize { cols: cols as usize, lines: rows as usize };
|
||||||
let term = Term::new(Config::default(), &size, VoidListener);
|
let replies = ReplyCollector::default();
|
||||||
Self { term, parser: Processor::new(), size }
|
let term = Term::new(Config::default(), &size, replies.clone());
|
||||||
|
Self { term, parser: Processor::new(), size, replies }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn feed(&mut self, bytes: &[u8]) {
|
pub fn feed(&mut self, bytes: &[u8]) {
|
||||||
self.parser.advance(&mut self.term, bytes);
|
self.parser.advance(&mut self.term, bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drain any escape sequences the model produced in response to queries fed so
|
||||||
|
/// 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resize(&mut self, cols: u16, rows: u16) {
|
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||||||
self.size = GridSize { cols: cols as usize, lines: rows as usize };
|
self.size = GridSize { cols: cols as usize, lines: rows as usize };
|
||||||
self.term.resize(self.size);
|
self.term.resize(self.size);
|
||||||
@@ -56,7 +89,7 @@ impl GridSurface {
|
|||||||
self.term.grid()[point].c
|
self.term.grid()[point].c
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn term(&self) -> &Term<VoidListener> {
|
pub fn term(&self) -> &Term<ReplyCollector> {
|
||||||
&self.term
|
&self.term
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,4 +130,15 @@ mod tests {
|
|||||||
assert_eq!(g.char_at(0, 0), 'a');
|
assert_eq!(g.char_at(0, 0), 'a');
|
||||||
assert_eq!(g.char_at(1, 0), 'c');
|
assert_eq!(g.char_at(1, 0), 'c');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn primary_device_attribute_query_gets_a_reply() {
|
||||||
|
// fish (and friends) send ESC[c at startup and block on the response.
|
||||||
|
let mut g = GridSurface::new(20, 5);
|
||||||
|
g.feed(b"\x1b[c");
|
||||||
|
let reply = g.take_replies();
|
||||||
|
assert!(reply.starts_with(b"\x1b[?"), "expected a DA1 reply, got {reply:?}");
|
||||||
|
// Replies are drained, not duplicated.
|
||||||
|
assert!(g.take_replies().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ pub enum Cmd {
|
|||||||
surface_id: Option<SurfaceId>,
|
surface_id: Option<SurfaceId>,
|
||||||
},
|
},
|
||||||
Health,
|
Health,
|
||||||
|
/// Which of the given CLI candidates are actually installed on the spawn PATH.
|
||||||
|
WhichAgents { candidates: Vec<String> },
|
||||||
Status,
|
Status,
|
||||||
Shutdown,
|
Shutdown,
|
||||||
GetConfig,
|
GetConfig,
|
||||||
|
|||||||
@@ -110,6 +110,98 @@ pub fn default_shell() -> String {
|
|||||||
"/bin/sh".into()
|
"/bin/sh".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's full PATH for spawning panels: their login-shell PATH (which sources
|
||||||
|
/// `.zprofile`/`.zshrc`), merged with the daemon's current PATH and common install
|
||||||
|
/// dirs. Cached for the daemon's lifetime.
|
||||||
|
///
|
||||||
|
/// Why: when the GUI launches the daemon (Finder/launchd), the inherited PATH is
|
||||||
|
/// minimal (`/usr/bin:/bin:…`), so agents like `claude`, `codex`, `gemini` — installed
|
||||||
|
/// in `~/.local/bin`, npm-global, or Homebrew — aren't found and the panel exits
|
||||||
|
/// immediately with "Process exited". A bare `/bin/zsh` still works, which is why
|
||||||
|
/// shells launched fine but agents didn't.
|
||||||
|
pub fn enriched_path() -> String {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static CACHE: OnceLock<String> = OnceLock::new();
|
||||||
|
CACHE
|
||||||
|
.get_or_init(|| {
|
||||||
|
let mut dirs: Vec<String> = Vec::new();
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
let mut merge = |src: &str| {
|
||||||
|
for d in src.split(':') {
|
||||||
|
if !d.is_empty() && seen.insert(d.to_string()) {
|
||||||
|
dirs.push(d.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(p) = login_shell_path() {
|
||||||
|
merge(&p);
|
||||||
|
}
|
||||||
|
if let Ok(p) = std::env::var("PATH") {
|
||||||
|
merge(&p);
|
||||||
|
}
|
||||||
|
merge(&fallback_path_dirs());
|
||||||
|
dirs.join(":")
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `cmd` is an executable resolvable on the spawn PATH (or an existing path
|
||||||
|
/// if it contains a slash). Used to only offer agents the user actually has installed.
|
||||||
|
pub fn is_installed(cmd: &str) -> bool {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
if cmd.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let is_exec = |p: &std::path::Path| {
|
||||||
|
p.metadata().map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false)
|
||||||
|
};
|
||||||
|
if cmd.contains('/') {
|
||||||
|
return is_exec(std::path::Path::new(cmd));
|
||||||
|
}
|
||||||
|
enriched_path().split(':').any(|dir| !dir.is_empty() && is_exec(&std::path::Path::new(dir).join(cmd)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common locations user-installed CLIs land in, as a colon-joined fallback.
|
||||||
|
fn fallback_path_dirs() -> String {
|
||||||
|
let mut v = vec![
|
||||||
|
"/opt/homebrew/bin".to_string(),
|
||||||
|
"/usr/local/bin".to_string(),
|
||||||
|
"/usr/bin".to_string(),
|
||||||
|
"/bin".to_string(),
|
||||||
|
"/usr/sbin".to_string(),
|
||||||
|
"/sbin".to_string(),
|
||||||
|
];
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
if !home.is_empty() {
|
||||||
|
for d in [".local/bin", ".npm-global/bin", ".cargo/bin", ".bun/bin", ".deno/bin", ".volta/bin", "go/bin"] {
|
||||||
|
v.push(format!("{home}/{d}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.join(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture PATH from the user's login+interactive shell so rc-file PATH edits apply.
|
||||||
|
/// Parses `env` output (the exported PATH is colon-joined regardless of shell, so this
|
||||||
|
/// works for fish too, where `$PATH` would otherwise print space-separated).
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn login_shell_path() -> Option<String> {
|
||||||
|
let shell = login_shell().or_else(|| std::env::var("SHELL").ok())?;
|
||||||
|
let out = std::process::Command::new(&shell)
|
||||||
|
.args(["-lic", "env"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("PATH="))
|
||||||
|
.map(|p| p.trim().to_string())
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn login_shell_path() -> Option<String> { None }
|
||||||
|
|
||||||
/// The current user's login shell from the passwd database (`getpwuid`).
|
/// The current user's login shell from the passwd database (`getpwuid`).
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn login_shell() -> Option<String> {
|
fn login_shell() -> Option<String> {
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ fn err(id: u64, code: &str, msg: &str) -> Envelope {
|
|||||||
/// Compute spawn env (hooks for claude agents, zsh integration for zsh shells)
|
/// Compute spawn env (hooks for claude agents, zsh integration for zsh shells)
|
||||||
/// and whether a deterministic hook source is active.
|
/// and whether a deterministic hook source is active.
|
||||||
fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (Vec<(String, String)>, bool) {
|
fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (Vec<(String, String)>, bool) {
|
||||||
if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
|
let (mut env, active) = if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
|
||||||
let env = crate::hooks::prepare(sid, &crate::hooks::spacesh_bin());
|
let env = crate::hooks::prepare(sid, &crate::hooks::spacesh_bin());
|
||||||
let active = !env.is_empty();
|
let active = !env.is_empty();
|
||||||
(env, active)
|
(env, active)
|
||||||
@@ -249,7 +249,13 @@ fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (
|
|||||||
(crate::hooks::shell_env(sid), false)
|
(crate::hooks::shell_env(sid), false)
|
||||||
} else {
|
} else {
|
||||||
(vec![], false)
|
(vec![], false)
|
||||||
|
};
|
||||||
|
// Ensure the child sees the user's full PATH; the GUI/launchd-launched daemon
|
||||||
|
// otherwise can't find agents (claude/codex/gemini) and the panel exits at once.
|
||||||
|
if !env.iter().any(|(k, _)| k == "PATH") {
|
||||||
|
env.push(("PATH".to_string(), crate::config::enriched_path()));
|
||||||
}
|
}
|
||||||
|
(env, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit a `layout_changed` event for a workspace's current tree.
|
/// Emit a `layout_changed` event for a workspace's current tree.
|
||||||
@@ -628,6 +634,11 @@ async fn handle_request(
|
|||||||
}))).await;
|
}))).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Cmd::WhichAgents { candidates } => {
|
||||||
|
let available: Vec<String> = candidates.into_iter().filter(|c| crate::config::is_installed(c)).collect();
|
||||||
|
let _ = out.send(ok(id, serde_json::json!({ "available": available }))).await;
|
||||||
|
}
|
||||||
|
|
||||||
Cmd::Status => {
|
Cmd::Status => {
|
||||||
let (groups, workspaces) = reg.status();
|
let (groups, workspaces) = reg.status();
|
||||||
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await;
|
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await;
|
||||||
|
|||||||
@@ -215,18 +215,20 @@ async fn run_actor(
|
|||||||
flush_deadline = Some(Instant::now() + FLUSH_INTERVAL);
|
flush_deadline = Some(Instant::now() + FLUSH_INTERVAL);
|
||||||
}
|
}
|
||||||
if pending.len() >= FLUSH_BYTES {
|
if pending.len() >= FLUSH_BYTES {
|
||||||
flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
let replies = flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
||||||
|
if !replies.is_empty() { let _ = pty.write_input(&replies); }
|
||||||
flush_deadline = None;
|
flush_deadline = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
let _ = flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = timer => {
|
_ = timer => {
|
||||||
flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
let replies = flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
||||||
|
if !replies.is_empty() { let _ = pty.write_input(&replies); }
|
||||||
flush_deadline = None;
|
flush_deadline = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +240,8 @@ async fn run_actor(
|
|||||||
|
|
||||||
/// Feed pending bytes into the grid, run detectors, broadcast output, and emit a
|
/// Feed pending bytes into the grid, run detectors, broadcast output, and emit a
|
||||||
/// state change (if any). No-op when pending is empty.
|
/// state change (if any). No-op when pending is empty.
|
||||||
|
/// Returns escape-sequence replies the terminal model produced (DA/DSR answers) that
|
||||||
|
/// the caller must write back to the PTY. Empty when there's nothing to feed or reply.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn flush(
|
fn flush(
|
||||||
pending: &mut Vec<u8>,
|
pending: &mut Vec<u8>,
|
||||||
@@ -248,9 +252,9 @@ fn flush(
|
|||||||
id: &SurfaceId,
|
id: &SurfaceId,
|
||||||
bcast: &broadcast::Sender<Vec<u8>>,
|
bcast: &broadcast::Sender<Vec<u8>>,
|
||||||
state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
||||||
) {
|
) -> Vec<u8> {
|
||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
return;
|
return Vec::new();
|
||||||
}
|
}
|
||||||
// Deterministic source: OSC 133 markers in this chunk.
|
// Deterministic source: OSC 133 markers in this chunk.
|
||||||
// Emit each distinct state transition immediately so no marker is dropped
|
// Emit each distinct state transition immediately so no marker is dropped
|
||||||
@@ -265,6 +269,9 @@ fn flush(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
grid.feed(&pending[..]);
|
grid.feed(&pending[..]);
|
||||||
|
// Answers to device-attribute / status queries the model just parsed; the actor
|
||||||
|
// writes these back to the PTY so query-blocking programs (fish) don't time out.
|
||||||
|
let replies = grid.take_replies();
|
||||||
// Best-effort fallback only when no deterministic source is active.
|
// Best-effort fallback only when no deterministic source is active.
|
||||||
if !had_osc && !*deterministic {
|
if !had_osc && !*deterministic {
|
||||||
if let Some(st) = FallbackScanner::scan(&grid.tail_text(6)) {
|
if let Some(st) = FallbackScanner::scan(&grid.tail_text(6)) {
|
||||||
@@ -275,6 +282,7 @@ fn flush(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = bcast.send(std::mem::take(pending));
|
let _ = bcast.send(std::mem::take(pending));
|
||||||
|
replies
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user