diff --git a/emrg/client/app.py b/emrg/client/app.py index b305b4d..6e93d38 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -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)", @@ -295,7 +361,7 @@ def render(self, ctx): "/memory": "Browse and search memories [session|project|]", "/rename": "Rename current session [title]", "/clear": "Clear current session history and start fresh", - "/rant": "Send feedback to the evolution system [@]", + "/rant": "Send feedback to the evolution system [/rant | /rant @ ]", "/version": "Show EMRG version and instance info", "/help": "Show keyboard shortcuts and commands", } @@ -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 @@ -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", "") @@ -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() @@ -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) @@ -1349,6 +1508,7 @@ async def handle_key(data: bytes) -> bool: /rename [title] Rename current session /rant Send feedback to evolution system /rant @ Rant to a specific project + /rant Interactive project picker, then type message quit / exit Exit EMRG Streaming @@ -1385,8 +1545,16 @@ async def handle_key(data: bytes) -> bool: if not message: if project: chat.add("system", "Usage: /rant @ ") - else: - chat.add("system", "Usage: /rant or /rant @ ") + 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 = { diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 8a0ee51..66f6ec6 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -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", "") @@ -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: