Add /configure command with full-screen editor
- AgentConfigStore: Per-agent YAML configs in ~/.xml-pipeline/agents/ - Full-screen editor using prompt_toolkit with YAML highlighting - /configure: Edit organism.yaml (swarm wiring) - /configure @agent: Edit agent config (prompt, model, temperature) Agent configs separate behavior (prompt, model) from wiring (peers, routing). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0796e45412
commit
0fb35da5dd
4 changed files with 591 additions and 7 deletions
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""
|
||||||
|
Configuration management for xml-pipeline.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Agent configs (~/.xml-pipeline/agents/*.yaml)
|
||||||
|
- Organism config (organism.yaml)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .agents import (
|
||||||
|
AgentConfig,
|
||||||
|
AgentConfigStore,
|
||||||
|
get_agent_config_store,
|
||||||
|
CONFIG_DIR,
|
||||||
|
AGENTS_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentConfig",
|
||||||
|
"AgentConfigStore",
|
||||||
|
"get_agent_config_store",
|
||||||
|
"CONFIG_DIR",
|
||||||
|
"AGENTS_DIR",
|
||||||
|
]
|
||||||
243
agentserver/config/agents.py
Normal file
243
agentserver/config/agents.py
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
"""
|
||||||
|
Agent configuration management.
|
||||||
|
|
||||||
|
Each agent has its own YAML config file in ~/.xml-pipeline/agents/
|
||||||
|
containing behavior settings (prompt, model, temperature, etc.)
|
||||||
|
|
||||||
|
The main organism.yaml defines wiring (listeners, peers, routing).
|
||||||
|
Agent YAML files define behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_DIR = Path.home() / ".xml-pipeline"
|
||||||
|
AGENTS_DIR = CONFIG_DIR / "agents"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentConfig:
|
||||||
|
"""
|
||||||
|
Configuration for an individual agent.
|
||||||
|
|
||||||
|
Stored in ~/.xml-pipeline/agents/{name}.yaml
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
|
||||||
|
# System prompt for the LLM
|
||||||
|
prompt: str = ""
|
||||||
|
|
||||||
|
# Model selection
|
||||||
|
model: str = "default" # "default", "claude-sonnet", "claude-opus", "gpt-4", etc.
|
||||||
|
|
||||||
|
# Generation parameters
|
||||||
|
temperature: float = 0.7
|
||||||
|
max_tokens: int = 4096
|
||||||
|
|
||||||
|
# Behavior flags
|
||||||
|
verbose: bool = False # Log detailed reasoning
|
||||||
|
confirm_actions: bool = False # Ask before tool calls
|
||||||
|
|
||||||
|
# Tool permissions (if empty, uses defaults from wiring)
|
||||||
|
allowed_tools: List[str] = field(default_factory=list)
|
||||||
|
blocked_tools: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Custom metadata
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dict for YAML serialization."""
|
||||||
|
d = asdict(self)
|
||||||
|
# Remove name from dict (it's the filename)
|
||||||
|
del d["name"]
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, name: str, data: Dict[str, Any]) -> "AgentConfig":
|
||||||
|
"""Create from dict (loaded from YAML)."""
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
prompt=data.get("prompt", ""),
|
||||||
|
model=data.get("model", "default"),
|
||||||
|
temperature=data.get("temperature", 0.7),
|
||||||
|
max_tokens=data.get("max_tokens", 4096),
|
||||||
|
verbose=data.get("verbose", False),
|
||||||
|
confirm_actions=data.get("confirm_actions", False),
|
||||||
|
allowed_tools=data.get("allowed_tools", []),
|
||||||
|
blocked_tools=data.get("blocked_tools", []),
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_yaml(self) -> str:
|
||||||
|
"""Serialize to YAML string."""
|
||||||
|
return yaml.dump(
|
||||||
|
self.to_dict(),
|
||||||
|
default_flow_style=False,
|
||||||
|
sort_keys=False,
|
||||||
|
allow_unicode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_yaml(cls, name: str, yaml_str: str) -> "AgentConfig":
|
||||||
|
"""Parse from YAML string."""
|
||||||
|
data = yaml.safe_load(yaml_str) or {}
|
||||||
|
return cls.from_dict(name, data)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentConfigStore:
|
||||||
|
"""
|
||||||
|
Manages agent configuration files.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
store = AgentConfigStore()
|
||||||
|
|
||||||
|
# Load or create config
|
||||||
|
config = store.get("greeter")
|
||||||
|
|
||||||
|
# Modify and save
|
||||||
|
config.prompt = "You are a friendly greeter."
|
||||||
|
store.save(config)
|
||||||
|
|
||||||
|
# Get path for editing
|
||||||
|
path = store.path_for("greeter")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, agents_dir: Path = AGENTS_DIR):
|
||||||
|
self.agents_dir = agents_dir
|
||||||
|
self._ensure_dir()
|
||||||
|
|
||||||
|
def _ensure_dir(self) -> None:
|
||||||
|
"""Create agents directory if needed."""
|
||||||
|
self.agents_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def path_for(self, name: str) -> Path:
|
||||||
|
"""Get path to agent's config file."""
|
||||||
|
return self.agents_dir / f"{name}.yaml"
|
||||||
|
|
||||||
|
def exists(self, name: str) -> bool:
|
||||||
|
"""Check if agent config exists."""
|
||||||
|
return self.path_for(name).exists()
|
||||||
|
|
||||||
|
def get(self, name: str) -> AgentConfig:
|
||||||
|
"""
|
||||||
|
Load agent config, creating default if not exists.
|
||||||
|
"""
|
||||||
|
path = self.path_for(name)
|
||||||
|
|
||||||
|
if path.exists():
|
||||||
|
with open(path) as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
return AgentConfig.from_dict(name, data)
|
||||||
|
|
||||||
|
# Return default config (not saved yet)
|
||||||
|
return AgentConfig(name=name)
|
||||||
|
|
||||||
|
def save(self, config: AgentConfig) -> Path:
|
||||||
|
"""
|
||||||
|
Save agent config to file.
|
||||||
|
|
||||||
|
Returns path to saved file.
|
||||||
|
"""
|
||||||
|
path = self.path_for(config.name)
|
||||||
|
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.dump(
|
||||||
|
config.to_dict(),
|
||||||
|
f,
|
||||||
|
default_flow_style=False,
|
||||||
|
sort_keys=False,
|
||||||
|
allow_unicode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def save_yaml(self, name: str, yaml_content: str) -> Path:
|
||||||
|
"""
|
||||||
|
Save raw YAML content for an agent.
|
||||||
|
|
||||||
|
Used when saving from editor.
|
||||||
|
"""
|
||||||
|
path = self.path_for(name)
|
||||||
|
|
||||||
|
# Validate YAML before saving
|
||||||
|
yaml.safe_load(yaml_content) # Raises on invalid YAML
|
||||||
|
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(yaml_content)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def load_yaml(self, name: str) -> str:
|
||||||
|
"""
|
||||||
|
Load raw YAML content for editing.
|
||||||
|
|
||||||
|
Returns default template if file doesn't exist.
|
||||||
|
"""
|
||||||
|
path = self.path_for(name)
|
||||||
|
|
||||||
|
if path.exists():
|
||||||
|
with open(path) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
# Return default template
|
||||||
|
return self._default_template(name)
|
||||||
|
|
||||||
|
def _default_template(self, name: str) -> str:
|
||||||
|
"""Generate default YAML template for new agent."""
|
||||||
|
return f"""# Agent configuration for: {name}
|
||||||
|
# This file defines behavior settings for the agent.
|
||||||
|
# The wiring (peers, routing) is in organism.yaml.
|
||||||
|
|
||||||
|
# System prompt - instructions for the LLM
|
||||||
|
prompt: |
|
||||||
|
You are {name}, an AI assistant.
|
||||||
|
|
||||||
|
Respond helpfully and concisely.
|
||||||
|
|
||||||
|
# Model selection: "default", "claude-sonnet", "claude-opus", "gpt-4", etc.
|
||||||
|
model: default
|
||||||
|
|
||||||
|
# Generation parameters
|
||||||
|
temperature: 0.7
|
||||||
|
max_tokens: 4096
|
||||||
|
|
||||||
|
# Behavior flags
|
||||||
|
verbose: false # Log detailed reasoning
|
||||||
|
confirm_actions: false # Ask before tool calls
|
||||||
|
|
||||||
|
# Tool permissions (empty = use defaults from wiring)
|
||||||
|
allowed_tools: []
|
||||||
|
blocked_tools: []
|
||||||
|
|
||||||
|
# Custom metadata (available to handler)
|
||||||
|
metadata: {{}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def list_agents(self) -> List[str]:
|
||||||
|
"""List all configured agents."""
|
||||||
|
return [
|
||||||
|
p.stem for p in self.agents_dir.glob("*.yaml")
|
||||||
|
]
|
||||||
|
|
||||||
|
def delete(self, name: str) -> bool:
|
||||||
|
"""Delete agent config file."""
|
||||||
|
path = self.path_for(name)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
_store: Optional[AgentConfigStore] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_config_store() -> AgentConfigStore:
|
||||||
|
"""Get the global agent config store."""
|
||||||
|
global _store
|
||||||
|
if _store is None:
|
||||||
|
_store = AgentConfigStore()
|
||||||
|
return _store
|
||||||
|
|
@ -10,6 +10,7 @@ import asyncio
|
||||||
import getpass
|
import getpass
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -26,11 +27,16 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
PROMPT_TOOLKIT_AVAILABLE = False
|
PROMPT_TOOLKIT_AVAILABLE = False
|
||||||
|
|
||||||
|
from ..config import get_agent_config_store, CONFIG_DIR
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_HOST = "127.0.0.1"
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
DEFAULT_PORT = 8765
|
DEFAULT_PORT = 8765
|
||||||
MAX_LOGIN_ATTEMPTS = 3
|
MAX_LOGIN_ATTEMPTS = 3
|
||||||
|
|
||||||
|
# Default organism config path
|
||||||
|
DEFAULT_ORGANISM_CONFIG = Path("config/organism.yaml")
|
||||||
|
|
||||||
|
|
||||||
class ConsoleClient:
|
class ConsoleClient:
|
||||||
"""
|
"""
|
||||||
|
|
@ -135,6 +141,8 @@ Available commands:
|
||||||
/status - Show server status
|
/status - Show server status
|
||||||
/listeners - List available targets
|
/listeners - List available targets
|
||||||
/targets - Alias for /listeners
|
/targets - Alias for /listeners
|
||||||
|
/configure - Edit organism.yaml (swarm wiring)
|
||||||
|
/configure @agent - Edit agent config (prompt, model, etc.)
|
||||||
/quit - Disconnect and exit
|
/quit - Disconnect and exit
|
||||||
|
|
||||||
Send messages:
|
Send messages:
|
||||||
|
|
@ -172,6 +180,23 @@ Send messages:
|
||||||
print(f" - {name}")
|
print(f" - {name}")
|
||||||
else:
|
else:
|
||||||
print("No targets available (pipeline not running)")
|
print("No targets available (pipeline not running)")
|
||||||
|
elif line == "/configure":
|
||||||
|
# Edit organism.yaml
|
||||||
|
await self._configure_organism()
|
||||||
|
elif line.startswith("/configure @"):
|
||||||
|
# Edit agent config: /configure @agent
|
||||||
|
agent_name = line[12:].strip()
|
||||||
|
if agent_name:
|
||||||
|
await self._configure_agent(agent_name)
|
||||||
|
else:
|
||||||
|
print("Usage: /configure @agent_name")
|
||||||
|
elif line.startswith("/configure "):
|
||||||
|
# Also support /configure agent without @
|
||||||
|
agent_name = line[11:].strip()
|
||||||
|
if agent_name:
|
||||||
|
await self._configure_agent(agent_name)
|
||||||
|
else:
|
||||||
|
print("Usage: /configure @agent_name")
|
||||||
elif line.startswith("/"):
|
elif line.startswith("/"):
|
||||||
print(f"Unknown command: {line}")
|
print(f"Unknown command: {line}")
|
||||||
elif line.startswith("@"):
|
elif line.startswith("@"):
|
||||||
|
|
@ -190,6 +215,72 @@ Send messages:
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def _configure_organism(self):
|
||||||
|
"""Open organism.yaml in editor."""
|
||||||
|
from .editor import edit_text, PROMPT_TOOLKIT_AVAILABLE as EDITOR_AVAILABLE
|
||||||
|
|
||||||
|
# Find organism.yaml
|
||||||
|
organism_path = DEFAULT_ORGANISM_CONFIG
|
||||||
|
if not organism_path.exists():
|
||||||
|
# Try absolute path in config dir
|
||||||
|
organism_path = CONFIG_DIR / "organism.yaml"
|
||||||
|
|
||||||
|
if not organism_path.exists():
|
||||||
|
print(f"organism.yaml not found at {organism_path}")
|
||||||
|
print("Create one or specify path in configuration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load content
|
||||||
|
content = organism_path.read_text()
|
||||||
|
|
||||||
|
if EDITOR_AVAILABLE:
|
||||||
|
# Use built-in editor
|
||||||
|
edited, saved = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: edit_text(content, title=f"organism.yaml")
|
||||||
|
)
|
||||||
|
|
||||||
|
if saved and edited is not None:
|
||||||
|
organism_path.write_text(edited)
|
||||||
|
print(f"Saved {organism_path}")
|
||||||
|
print("Note: Restart server to apply changes.")
|
||||||
|
else:
|
||||||
|
print("Cancelled.")
|
||||||
|
else:
|
||||||
|
print(f"Edit manually: {organism_path}")
|
||||||
|
|
||||||
|
async def _configure_agent(self, agent_name: str):
|
||||||
|
"""Open agent config in editor."""
|
||||||
|
from .editor import edit_text, PROMPT_TOOLKIT_AVAILABLE as EDITOR_AVAILABLE
|
||||||
|
|
||||||
|
store = get_agent_config_store()
|
||||||
|
|
||||||
|
# Load or create config content
|
||||||
|
content = store.load_yaml(agent_name)
|
||||||
|
config_path = store.path_for(agent_name)
|
||||||
|
|
||||||
|
if EDITOR_AVAILABLE:
|
||||||
|
# Use built-in editor
|
||||||
|
edited, saved = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: edit_text(content, title=f"Agent: {agent_name}")
|
||||||
|
)
|
||||||
|
|
||||||
|
if saved and edited is not None:
|
||||||
|
try:
|
||||||
|
store.save_yaml(agent_name, edited)
|
||||||
|
print(f"Saved {config_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error saving: {e}")
|
||||||
|
else:
|
||||||
|
print("Cancelled.")
|
||||||
|
else:
|
||||||
|
# Fallback: show path
|
||||||
|
if not config_path.exists():
|
||||||
|
# Create default
|
||||||
|
store.save_yaml(agent_name, content)
|
||||||
|
print(f"Edit manually: {config_path}")
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
"""Main client loop."""
|
"""Main client loop."""
|
||||||
if not AIOHTTP_AVAILABLE:
|
if not AIOHTTP_AVAILABLE:
|
||||||
|
|
|
||||||
227
agentserver/console/editor.py
Normal file
227
agentserver/console/editor.py
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
"""
|
||||||
|
Full-screen text editor using prompt_toolkit.
|
||||||
|
|
||||||
|
Provides a vim-like editing experience for configuration files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
from prompt_toolkit import Application
|
||||||
|
from prompt_toolkit.buffer import Buffer
|
||||||
|
from prompt_toolkit.layout import Layout, HSplit, VSplit
|
||||||
|
from prompt_toolkit.layout.containers import Window, ConditionalContainer
|
||||||
|
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
||||||
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
from prompt_toolkit.filters import Condition
|
||||||
|
from prompt_toolkit.styles import Style
|
||||||
|
from prompt_toolkit.lexers import PygmentsLexer
|
||||||
|
PROMPT_TOOLKIT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PROMPT_TOOLKIT_AVAILABLE = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pygments.lexers.data import YamlLexer
|
||||||
|
PYGMENTS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PYGMENTS_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
def edit_text(
|
||||||
|
initial_text: str,
|
||||||
|
title: str = "Editor",
|
||||||
|
syntax: str = "yaml",
|
||||||
|
) -> Tuple[Optional[str], bool]:
|
||||||
|
"""
|
||||||
|
Open full-screen editor for text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
initial_text: Text to edit
|
||||||
|
title: Title shown in header
|
||||||
|
syntax: Syntax highlighting ("yaml", "text")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(edited_text, saved) - edited_text is None if cancelled
|
||||||
|
"""
|
||||||
|
if not PROMPT_TOOLKIT_AVAILABLE:
|
||||||
|
print("Error: prompt_toolkit not installed")
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
# State
|
||||||
|
result = {"text": None, "saved": False}
|
||||||
|
|
||||||
|
# Create buffer with initial text
|
||||||
|
buffer = Buffer(
|
||||||
|
multiline=True,
|
||||||
|
name="editor",
|
||||||
|
)
|
||||||
|
buffer.text = initial_text
|
||||||
|
|
||||||
|
# Key bindings
|
||||||
|
kb = KeyBindings()
|
||||||
|
|
||||||
|
@kb.add("c-s") # Ctrl+S to save
|
||||||
|
def save(event):
|
||||||
|
result["text"] = buffer.text
|
||||||
|
result["saved"] = True
|
||||||
|
event.app.exit()
|
||||||
|
|
||||||
|
@kb.add("c-q") # Ctrl+Q to quit without saving
|
||||||
|
def quit_nosave(event):
|
||||||
|
result["text"] = None
|
||||||
|
result["saved"] = False
|
||||||
|
event.app.exit()
|
||||||
|
|
||||||
|
@kb.add("escape") # Escape to quit
|
||||||
|
def escape(event):
|
||||||
|
result["text"] = None
|
||||||
|
result["saved"] = False
|
||||||
|
event.app.exit()
|
||||||
|
|
||||||
|
# Syntax highlighting
|
||||||
|
lexer = None
|
||||||
|
if PYGMENTS_AVAILABLE and syntax == "yaml":
|
||||||
|
lexer = PygmentsLexer(YamlLexer)
|
||||||
|
|
||||||
|
# Layout
|
||||||
|
header = Window(
|
||||||
|
height=1,
|
||||||
|
content=FormattedTextControl(
|
||||||
|
lambda: [
|
||||||
|
("class:header", f" {title} "),
|
||||||
|
("class:header.key", " Ctrl+S"),
|
||||||
|
("class:header", "=Save "),
|
||||||
|
("class:header.key", " Ctrl+Q"),
|
||||||
|
("class:header", "=Quit "),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
style="class:header",
|
||||||
|
)
|
||||||
|
|
||||||
|
editor_window = Window(
|
||||||
|
content=BufferControl(
|
||||||
|
buffer=buffer,
|
||||||
|
lexer=lexer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status bar showing cursor position
|
||||||
|
def get_status():
|
||||||
|
row = buffer.document.cursor_position_row + 1
|
||||||
|
col = buffer.document.cursor_position_col + 1
|
||||||
|
lines = len(buffer.text.split("\n"))
|
||||||
|
return [
|
||||||
|
("class:status", f" Line {row}/{lines}, Col {col} "),
|
||||||
|
]
|
||||||
|
|
||||||
|
status_bar = Window(
|
||||||
|
height=1,
|
||||||
|
content=FormattedTextControl(get_status),
|
||||||
|
style="class:status",
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = Layout(
|
||||||
|
HSplit([
|
||||||
|
header,
|
||||||
|
editor_window,
|
||||||
|
status_bar,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Styles
|
||||||
|
style = Style.from_dict({
|
||||||
|
"header": "bg:#005f87 #ffffff",
|
||||||
|
"header.key": "bg:#005f87 #ffff00 bold",
|
||||||
|
"status": "bg:#444444 #ffffff",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create and run application
|
||||||
|
app = Application(
|
||||||
|
layout=layout,
|
||||||
|
key_bindings=kb,
|
||||||
|
style=style,
|
||||||
|
full_screen=True,
|
||||||
|
mouse_support=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
return result["text"], result["saved"]
|
||||||
|
|
||||||
|
|
||||||
|
def edit_file(filepath: str, title: Optional[str] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Edit a file in the full-screen editor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to file
|
||||||
|
title: Optional title (defaults to filename)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if saved, False if cancelled
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(filepath)
|
||||||
|
title = title or path.name
|
||||||
|
|
||||||
|
# Load existing content or empty
|
||||||
|
if path.exists():
|
||||||
|
initial_text = path.read_text()
|
||||||
|
else:
|
||||||
|
initial_text = ""
|
||||||
|
|
||||||
|
# Edit
|
||||||
|
edited_text, saved = edit_text(initial_text, title=title, syntax="yaml")
|
||||||
|
|
||||||
|
# Save if requested
|
||||||
|
if saved and edited_text is not None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(edited_text)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Fallback: use system editor via subprocess
|
||||||
|
def edit_with_system_editor(filepath: str) -> bool:
|
||||||
|
"""
|
||||||
|
Edit file using system's default editor ($EDITOR or fallback).
|
||||||
|
|
||||||
|
Returns True if file was modified.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(filepath)
|
||||||
|
|
||||||
|
# Get editor from environment
|
||||||
|
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", ""))
|
||||||
|
|
||||||
|
if not editor:
|
||||||
|
# Fallback based on platform
|
||||||
|
import platform
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
editor = "notepad"
|
||||||
|
else:
|
||||||
|
editor = "nano" # Most likely available
|
||||||
|
|
||||||
|
# Get modification time before edit
|
||||||
|
mtime_before = path.stat().st_mtime if path.exists() else None
|
||||||
|
|
||||||
|
# Open editor
|
||||||
|
try:
|
||||||
|
subprocess.run([editor, str(path)], check=True)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Editor not found: {editor}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if modified
|
||||||
|
if path.exists():
|
||||||
|
mtime_after = path.stat().st_mtime
|
||||||
|
return mtime_before is None or mtime_after > mtime_before
|
||||||
|
|
||||||
|
return False
|
||||||
Loading…
Reference in a new issue