Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 171 additions & 3 deletions emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,72 @@ def render(self, ctx):
return lines


class ProjectSelector(Widget):
"""Interactive project picker — arrow-key navigation with highlight.

Renders a list of projects from projects.yml with the selected one in
reverse video. Used by /rant when invoked without @project.
"""

def __init__(self, projects: list[dict] | None = None):
self.projects: list[dict] = projects or []
self.selected_index: int = 0
self._dirty: bool = True

@property
def dirty(self) -> bool:
return self._dirty

@dirty.setter
def dirty(self, value: bool) -> None:
self._dirty = value

def move_up(self) -> None:
if self.selected_index > 0:
self.selected_index -= 1
self._dirty = True

def move_down(self) -> None:
if self.selected_index < len(self.projects) - 1:
self.selected_index += 1
self._dirty = True

@property
def selected_project_name(self) -> str | None:
if 0 <= self.selected_index < len(self.projects):
return self.projects[self.selected_index].get("name", "")
return None

def render(self, ctx):
from rich.style import Style
lines: list[Line] = []
pstyle = Style.parse("bold cyan")
lines.append(Line(
spans=[Span("○ ", style="dim"), Span("Select a project (↑↓/j/k to move, Enter to confirm, Esc to cancel):", style="bold")],
style=ctx.style,
))
for i, p in enumerate(self.projects):
name = p.get("name", "?")
repo = p.get("repo", "")
auto = "🔄" if p.get("auto_evolve") else "💬"
label = f" {auto} {name}"
if repo:
label += f" ({repo})"
if i == self.selected_index:
spans = [
Span("> ", style=pstyle),
Span(label, style=Style(reverse=True)),
]
else:
spans = [
Span(" ", style=ctx.style),
Span(label, style=ctx.style),
]
lines.append(Line(spans=spans, style=ctx.style))
self._dirty = False
return lines


# Command help text for autocomplete dropdown
_COMMAND_HELP: dict[str, str] = {
"/resume": "Switch to a session by [id] or interactively (↑↓/j/k to pick)",
Expand All @@ -295,7 +361,7 @@ def render(self, ctx):
"/memory": "Browse and search memories [session|project|<id>]",
"/rename": "Rename current session [title]",
"/clear": "Clear current session history and start fresh",
"/rant": "Send feedback to the evolution system [@<project>]",
"/rant": "Send feedback to the evolution system [/rant | /rant @<project> <msg>]",
"/version": "Show EMRG version and instance info",
"/help": "Show keyboard shortcuts and commands",
}
Expand Down Expand Up @@ -619,6 +685,12 @@ async def _run_elapsed_timer() -> None:
selector_widget: SessionSelector | None = None
_pending_resume_select = False # True when /resume typed without args, waiting for sessions_list

# Project selector state (interactive /rant project picker)
project_selector_active = False
project_selector_widget: ProjectSelector | None = None
_pending_rant_project = False # True when /rant typed without args, waiting for projects_list
_rant_project: str | None = None # Set after project selection, used on next Enter

# Command autocomplete state (shows dropdown when user types /)
_autocomplete_active = False
_autocomplete_widget: CommandDropdown | None = None
Expand Down Expand Up @@ -826,6 +898,32 @@ async def read_server():
term.render()
continue

# Projects list
if data.get("type") == "projects_list":
nonlocal project_selector_active, project_selector_widget, _pending_rant_project
projects = data.get("projects", [])
err = data.get("error", "")
if err:
chat.add("system", f"Error: {err}")
_pending_rant_project = False
elif _pending_rant_project and projects:
_pending_rant_project = False
project_selector_widget = ProjectSelector(projects)
project_selector_active = True
chat.add(project_selector_widget)
status.update(center="select project: ↑↓ Enter Esc (j/k vim)")
else:
_pending_rant_project = False
if projects:
project_selector_widget = ProjectSelector(projects)
project_selector_active = True
chat.add(project_selector_widget)
status.update(center="select project: ↑↓ Enter Esc (j/k vim)")
else:
chat.add("system", "No projects configured. Use emrg in a git repo to auto-register.")
term.render()
continue

# Resume result
if data.get("type") == "resume_result":
err = data.get("error", "")
Expand Down Expand Up @@ -1050,6 +1148,51 @@ async def handle_key(data: bytes) -> bool:
# Ignore other keys when in selector mode
return True

# ── Project selector mode ──────────────────────────
if project_selector_active and project_selector_widget:
if data == b"\x1b": # Esc — cancel selection
project_selector_active = False
chat.add("system", "Project selection cancelled.")
project_selector_widget = None
status.update(center=server_id or "emrg")
chat.dirty = True; term.render()
return True
if data == b"\r" or data == b"\n": # Enter — confirm
pname = project_selector_widget.selected_project_name
project_selector_active = False
project_selector_widget = None
if pname:
nonlocal _rant_project
_rant_project = pname
chat.add("system", f"Rant to project '@{pname}' — type your message and press Enter:")
status.update(center=f"rant to @{pname}")
else:
chat.add("system", "No project selected.")
status.update(center=server_id or "emrg")
chat.dirty = True; term.render()
return True
if len(data) >= 3 and data[0] == 0x1B and data[1] == 0x5B:
c = data[2]
if c == 0x41: # Up
project_selector_widget.move_up()
chat.dirty = True; term.render()
return True
elif c == 0x42: # Down
project_selector_widget.move_down()
chat.dirty = True; term.render()
return True
# j/k for vim-style navigation
if data == b"j":
project_selector_widget.move_down()
chat.dirty = True; term.render()
return True
if data == b"k":
project_selector_widget.move_up()
chat.dirty = True; term.render()
return True
# Ignore other keys when in project selector mode
return True

# ── Command autocomplete: recompute on every keystroke ──
if not selector_active and not busy:
text_stripped = inp.text.lstrip()
Expand Down Expand Up @@ -1220,6 +1363,22 @@ async def handle_key(data: bytes) -> bool:
if text:
if text.lower() in ("quit", "exit"): return False

# If a rant project was selected, use this message as the rant
if _rant_project:
payload = {
"type": "rant",
"message": text,
"project": _rant_project,
"timestamp": datetime.now().isoformat(),
}
writer.write(json.dumps(payload).encode() + b"\n")
await writer.drain()
chat.add("system", f"Rant recorded (@{_rant_project}). The evolution system will review it.")
_rant_project = None
status.update(center=server_id or "emrg")
inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render()
return True

# Handle /memory command
if text.lower().startswith("/memory"):
parts = text.split(None, 1)
Expand Down Expand Up @@ -1349,6 +1508,7 @@ async def handle_key(data: bytes) -> bool:
/rename [title] Rename current session
/rant <msg> Send feedback to evolution system
/rant @<project> <msg> Rant to a specific project
/rant Interactive project picker, then type message
quit / exit Exit EMRG

Streaming
Expand Down Expand Up @@ -1385,8 +1545,16 @@ async def handle_key(data: bytes) -> bool:
if not message:
if project:
chat.add("system", "Usage: /rant @<project> <message>")
else:
chat.add("system", "Usage: /rant <message> or /rant @<project> <message>")
inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render()
return True
# /rant without args → interactive project selector
nonlocal _pending_rant_project
_pending_rant_project = True
writer.write(json.dumps({
"type": "list_projects",
}).encode() + b"\n")
await writer.drain()
status.update(center="loading projects...")
inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render()
return True
payload = {
Expand Down
24 changes: 24 additions & 0 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,9 @@ async def _process_message(
count, f" project={project}" if project else "", rant_message[:100])
await self._send(writer, {"ok": True, "count": count})

elif msg_type == "list_projects":
await self._handle_list_projects(writer)

elif msg_type == "clear_session":
session_id = msg.get("session_id", "")
cwd = msg.get("cwd", "")
Expand Down Expand Up @@ -1784,6 +1787,27 @@ async def _handle_list_sessions(
"sessions": sessions,
})

async def _handle_list_projects(
self, writer: asyncio.StreamWriter
) -> None:
"""Read projects.yml and return all project entries."""
projects: list[dict] = []
try:
if self._projects_log.exists():
data = yaml.safe_load(self._projects_log.read_text())
if isinstance(data, list):
projects = [
{"name": p.get("name", ""), "repo": p.get("repo", ""),
"path": p.get("path", ""), "auto_evolve": p.get("auto_evolve", False)}
for p in data if isinstance(p, dict)
]
except (yaml.YAMLError, OSError) as e:
logger.warning("Failed to read projects.yml: %s", e)
await self._send(writer, {
"type": "projects_list",
"projects": projects,
})

async def _handle_resume_session(
self, session_id: str, cwd: Path, writer: asyncio.StreamWriter
) -> None:
Expand Down
Loading