"""
Editor integration utilities for Continuity.
Provides smart editor detection and positioning for frictionless workflow.
"""
import contextlib
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import List, Optional
from rich.console import Console
console = Console()
[docs]
class EditorConfig:
"""Configuration for different editor positioning support."""
# Editor positioning syntax mapping
EDITOR_CONFIGS = {
"vim": "+{line}",
"nvim": "+{line}",
"neovim": "+{line}",
"vi": "+{line}",
"nano": "+{line}",
"emacs": "+{line}:0",
"code": "--goto {file}:{line}:1",
"codium": "--goto {file}:{line}:1",
"subl": "{file}:{line}:1",
"sublime_text": "{file}:{line}:1",
"atom": "{file}:{line}:1",
"gedit": "+{line}",
"kate": "--line {line}",
"kwrite": "--line {line}",
}
# Editors that need special handling
GUI_EDITORS = {
"code",
"codium",
"subl",
"sublime_text",
"atom",
"gedit",
"kate",
"kwrite",
}
WAIT_FLAG_EDITORS = {
"code": "--wait",
"codium": "--wait",
"subl": "--wait",
"atom": "--wait",
}
[docs]
def detect_editor() -> str:
"""Detect the best available editor.
Returns:
Editor command string
"""
# Check environment variables first
for env_var in ["VISUAL", "EDITOR"]:
editor = os.environ.get(env_var)
if editor and shutil.which(editor.split()[0]):
return editor
# Check common editor locations
common_editors = ["vim", "nvim", "nano", "emacs", "code", "vi"]
for editor in common_editors:
if shutil.which(editor):
return editor
# POSIX fallback
return "vi"
[docs]
def get_editor_name(editor_command: str) -> str:
"""Extract the base editor name from a command.
Args:
editor_command: Full editor command (may include flags)
Returns:
Base editor name
"""
# Handle complex commands like "code --disable-extensions"
base_command = editor_command.split()[0]
# Strip path to get just the executable name
return Path(base_command).name
[docs]
def build_editor_command(
editor_command: str, file_path: str, line_number: int = 1
) -> List[str]:
"""Build editor command with positioning.
Args:
editor_command: Editor command to use
file_path: Path to file to edit
line_number: Line number to position cursor
Returns:
Command list ready for subprocess
"""
editor_name = get_editor_name(editor_command)
editor_parts = editor_command.split()
# Check if we have positioning support for this editor
if editor_name in EditorConfig.EDITOR_CONFIGS:
position_arg = EditorConfig.EDITOR_CONFIGS[editor_name]
# Handle different positioning patterns
if "{file}" in position_arg:
# Editors like VS Code that include file in position arg
position_formatted = position_arg.format(file=file_path, line=line_number)
command = editor_parts + [position_formatted]
else:
# Traditional editors with separate position and file args
position_formatted = position_arg.format(line=line_number)
command = editor_parts + [position_formatted, file_path]
# Add wait flag for GUI editors if needed
if editor_name in EditorConfig.WAIT_FLAG_EDITORS:
wait_flag = EditorConfig.WAIT_FLAG_EDITORS[editor_name]
# Insert wait flag before position arguments
command = (
editor_parts + [wait_flag, position_formatted, file_path]
if "{file}" not in position_arg
else editor_parts + [wait_flag, position_formatted]
)
else:
# Fallback: no positioning, just open the file
command = editor_parts + [file_path]
return command
[docs]
def create_message_template(
title: str = "", to: str = "agent", from_user: str = "human", config=None
) -> str:
"""Create a message template with frontmatter.
Args:
title: Message title
to: Recipient (internal username or alias)
from_user: Sender (internal username or alias)
config: ContinuityConfig instance for display name mapping
Returns:
Template content with positioning markers
"""
# Map internal usernames to display names for template
display_to = to
display_from = from_user
if config:
# Map AI agent internal name to display nickname
if to == config.ai_user or to == "agent":
display_to = config.ai_agent_nickname
if from_user == config.ai_user or from_user == "agent":
display_from = config.ai_agent_nickname
# Use Mike's preferred format: title first, then frontmatter
template = f"""# {title or "Message Title"}
---
from: {display_from}
to: {display_to}
---
[Write your message here]
"""
return template
[docs]
def find_content_line(template: str) -> int:
"""Find the line number where user should start typing.
Args:
template: Template content
Returns:
Line number (1-based) for cursor positioning
"""
lines = template.split("\n")
# Look for the content marker
for i, line in enumerate(lines, 1):
if "[Write your message here]" in line:
return i
# Fallback: find first empty line after frontmatter
in_frontmatter = False
frontmatter_end = 0
for i, line in enumerate(lines, 1):
if line.strip() == "---":
if in_frontmatter:
frontmatter_end = i
break
else:
in_frontmatter = True
# Return line after frontmatter + title
return frontmatter_end + 3
[docs]
def open_editor_with_template(
template: str, editor_command: Optional[str] = None
) -> Optional[str]:
"""Open editor with template and return content.
Args:
template: Template content to pre-fill
editor_command: Specific editor to use (auto-detect if None)
Returns:
Edited content, or None if editing was cancelled
"""
if not editor_command:
editor_command = detect_editor()
# Create temporary file
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as tmp_file:
tmp_file.write(template)
tmp_path = tmp_file.name
try:
# Find optimal cursor position
content_line = find_content_line(template)
# Build editor command with positioning
command = build_editor_command(editor_command, tmp_path, content_line)
# Execute editor
try:
subprocess.run(command, check=True)
# Read the edited content
with open(tmp_path, encoding="utf-8") as f:
content = f.read()
return content
except subprocess.CalledProcessError:
console.print("[yellow]Editor was closed without saving[/yellow]")
return None
except FileNotFoundError:
console.print(f"[red]Editor not found: {editor_command}[/red]")
console.print(
"[yellow]Tip:[/yellow] Set EDITOR environment variable or install a supported editor"
)
return None
finally:
# Clean up temporary file
with contextlib.suppress(OSError):
os.unlink(tmp_path)
[docs]
def test_editor_positioning(editor_command: Optional[str] = None) -> None:
"""Test editor positioning functionality.
Args:
editor_command: Editor to test (auto-detect if None)
"""
if not editor_command:
editor_command = detect_editor()
editor_name = get_editor_name(editor_command)
console.print(f"[blue]Testing editor:[/blue] {editor_command}")
console.print(f"[blue]Editor name:[/blue] {editor_name}")
test_template = create_message_template("Test Message", "ai_user", "test-user")
content_line = find_content_line(test_template)
console.print(f"[blue]Content line:[/blue] {content_line}")
command = build_editor_command(editor_command, "test.md", content_line)
console.print(f"[blue]Command:[/blue] {' '.join(command)}")
has_positioning = editor_name in EditorConfig.EDITOR_CONFIGS
console.print(
f"[blue]Positioning support:[/blue] {'✓' if has_positioning else '✗'}"
)