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
24 changes: 12 additions & 12 deletions emrg/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def _build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--init-auto-evolve",
action="store_true",
help="Enable auto-evolution for the current project on connect.",
help="Enable auto-evolution for the current agent on connect.",
)

sub = parser.add_subparsers(dest="command", metavar="[command]")
Expand Down Expand Up @@ -99,16 +99,16 @@ def _build_parser() -> argparse.ArgumentParser:
description="Update emrg by pulling latest source and reinstalling.",
)

# emrg rant [--project <name>] <message>
# emrg rant [--agent <name>] <message>
rant_parser = sub.add_parser(
"rant",
help="Send feedback/complaint to EMRG for evolution analysis",
description="Send a rant (feedback, complaint, suggestion) that the "
"evolution system will use to discover improvement opportunities.",
)
rant_parser.add_argument(
"-p", "--project",
help="Target project (from agents.yml). Omit for emrg itself.",
"-p", "--agent",
help="Target agent (from agents.yml). Omit for emrg itself.",
default=None,
)
rant_parser.add_argument(
Expand Down Expand Up @@ -160,7 +160,7 @@ def main() -> None:
else:
_run_daemon()
elif parsed.command == "rant":
_send_rant(" ".join(parsed.message), project=parsed.project)
_send_rant(" ".join(parsed.message), agent=parsed.agent)
elif parsed.command == "stop":
# Windows installer pre-stop depends on the non-zero exit code when
# residual processes remain (emrg/_stop_all.py owns the full logic).
Expand Down Expand Up @@ -417,13 +417,13 @@ def _run_daemon() -> None:

# ── Rant ──────────────────────────────────────────────────────

def _send_rant(message: str, project: str | None = None) -> None:
def _send_rant(message: str, agent: str | None = None) -> None:
"""Send a rant/feedback message to the daemon for evolution analysis."""
# Parse @project from message if -p/--project not explicitly set
# (matching TUI /rant @project behavior — app.py:1365-1369)
if project is None and message.startswith("@"):
# Parse @agent from message if -p/--agent not explicitly set
# (matching TUI /rant @agent behavior — app.py:1365-1369)
if agent is None and message.startswith("@"):
parts = message.split(None, 1)
project = parts[0][1:] # strip @
agent = parts[0][1:] # strip @
message = parts[1] if len(parts) > 1 else ""

async def _do() -> None:
Expand All @@ -444,8 +444,8 @@ async def _do() -> None:
"message": message,
"timestamp": datetime.now().isoformat(),
}
if project:
payload["project"] = project
if agent:
payload["agent"] = agent

await ws.send(json.dumps(payload, ensure_ascii=False))

Expand Down
182 changes: 91 additions & 91 deletions emrg/client/app.py

Large diffs are not rendered by default.

40 changes: 20 additions & 20 deletions emrg/client/widgets.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""EMRG client — TUI widget classes extracted from app.py.

Contains InputWidget, selectors (Session/Project/Model/Rewind/Command),
Contains InputWidget, selectors (Session/Agent/Model/Rewind/Command),
ChatHistory, _COMMAND_HELP, and SelectorState.
"""

Expand Down Expand Up @@ -657,15 +657,15 @@ def _format_tool_input(arguments: dict) -> list[str]:
return text.split("\n")


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

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

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

Expand All @@ -683,25 +683,25 @@ def move_up(self) -> None:
self._dirty = True

def move_down(self) -> None:
if self.selected_index < len(self.projects) - 1:
if self.selected_index < len(self.agents) - 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", "")
def selected_agent_name(self) -> str | None:
if 0 <= self.selected_index < len(self.agents):
return self.agents[self.selected_index].get("name", "")
return None

def render(self, ctx):
lines: list[Line] = []
pstyle = Style.parse("bold cyan")
avail = getattr(ctx, "height", 0) or 0
n = len(self.projects)
n = len(self.agents)
chrome, start, end, markers = window_plan(avail, n, self.selected_index)
if chrome:
lines.append(Line(
spans=[Span("○ ", style="dim"), Span("Select a project (↑↓/j/k to move, Enter to confirm, Esc to cancel):", style="bold")],
spans=[Span("○ ", style="dim"), Span("Select an agent (↑↓/j/k to move, Enter to confirm, Esc to cancel):", style="bold")],
style=ctx.style,
))
if markers and start > 0:
Expand All @@ -710,7 +710,7 @@ def render(self, ctx):
style=ctx.style,
))
for i in range(start, end):
p = self.projects[i]
p = self.agents[i]
name = p.get("name", "?")
repo = p.get("repo", "")
label = f" {name}"
Expand Down Expand Up @@ -878,7 +878,7 @@ def selected_task_name(self) -> str | None:

@property
def selected_task(self) -> dict | None:
"""The whole selected row — /task-session needs `project_path` and
"""The whole selected row — /task-session needs `agent_path` and
`session_id`, which `selected_task_name` does not carry."""
if 0 <= self.selected_index < len(self.tasks):
return self.tasks[self.selected_index]
Expand Down Expand Up @@ -1030,18 +1030,18 @@ def render(self, ctx):
"/sessions": "Browse and switch between saved sessions (↑↓/j/k to navigate)",
"/rewind": "Rewind session — pick a user message to truncate history to",
"/compact": "Compress conversation history to save context",
"/memory": "Browse and search memories [session|project|<id>]",
"/memory": "Browse and search memories [session|agent|<id>]",
"/rename": "Rename current session [title]",
"/delete": "Delete a session [/delete | /delete <session_id>]",
"/clear": "Clear current session history and start fresh",
"/rant": "Send feedback to the evolution system [/rant | /rant @<project> <msg>]",
"/rant": "Send feedback to the evolution system [/rant | /rant @<agent> <msg>]",
"/model": "Switch LLM model [/model | /model <name>]",
"/sandbox": "Set this session's sandbox tier, or its extra writable roots "
"[/sandbox | /sandbox <mode> | /sandbox add|remove|list <path>] "
"[/sandbox add|remove --registry <key> names a registry key]",
"/trigger": "List or manually trigger scheduled tasks [/trigger | /trigger <name>]",
"/task-session": "Open a scheduled task's session (no args = interactive picker)",
"/skills": "List loaded skills (user + project)",
"/skills": "List loaded skills (user + agent)",
"/version": "Show EMRG version and instance info",
"/image": "Insert clipboard image into input field",
"/help": "Show keyboard shortcuts and commands",
Expand Down Expand Up @@ -1259,7 +1259,7 @@ def render(self, ctx):


class SelectorState:
"""Unified state for interactive selectors (session, project, model).
"""Unified state for interactive selectors (session, agent, model).

Consolidates 9 separate variables into 3 typed instances to eliminate
nonlocal declaration errors (rant #31).
Expand All @@ -1268,5 +1268,5 @@ class SelectorState:

def __init__(self) -> None:
self.active: bool = False
self.widget: 'SessionSelector | ProjectSelector | ModelSelector | None' = None
self.widget: 'SessionSelector | AgentSelector | ModelSelector | None' = None
self.pending: bool = False
12 changes: 6 additions & 6 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ function isSessionId(id) {
const RESPONSE_TYPES = {
list_sessions: "sessions_list",
list_models: "models_list",
list_projects: "projects_list",
list_agents: "agents_list",
list_history: "history_list",
// The prompts the host typed in one session (issue #2349, part 2/2): the daemon
// answers a `list_input_history` command with an `input_history` frame, so the
Expand Down Expand Up @@ -164,8 +164,8 @@ const RESPONSE_TYPES = {
*
* The daemon answers where a session lives so that a client does not have to
* guess (issue #2484). Two answers are deliberately not the same value: `index`
* carries the project the global session index holds, and `unknown` carries
* `null` — the daemon cannot vouch for that session's project, so the requested
* carries the agent the global session index holds, and `unknown` carries
* `null` — the daemon cannot vouch for that session's agent, so the requested
* cwd comes back only as a candidate and must not be relabelled as a fact.
*
* So everything except a non-empty `index` cwd answers the caller's fallback,
Expand Down Expand Up @@ -547,7 +547,7 @@ class DaemonClient {
errFd === null ? null : EMRGD_START_ERR()
);
}
// G125:spawn 设 cwd=project_dir(daemon load_skills 用 Path.cwd() 加载项目级 skills)
// G125:spawn 设 cwd=agent_dir(daemon load_skills 用 Path.cwd() 加载项目级 skills)
const python = this._findPython();
const args = ["-m", "emrg.server"];
this.logger.info(`[gui] spawning daemon: ${python} ${args.join(" ")} cwd=${os.homedir()}`);
Expand Down Expand Up @@ -1172,7 +1172,7 @@ class DaemonClient {
// both (`app.py:1345` / `:1353`), so this layer was the only client that
// dropped them: they matched no branch, fell to the unknown-frame log at
// the bottom, and reached no renderer. The catch-all's list of *expected*
// drops (`templates_list` / `compact_result` / `projects_list`) never named
// drops (`templates_list` / `compact_result` / `agents_list`) never named
// them.
this._emit(frame.type, frame);
return;
Expand Down Expand Up @@ -1200,7 +1200,7 @@ class DaemonClient {
// 日志以 `type=` 开头,下一次可以 `grep 'unknown frame type=' | sort | uniq -c`
// 直接数,而不必从截断的 JSON 里抠类型。
// 注意:本支对某些帧是**预期**的——daemon 会把 `templates_list` / `compact_result`
// / `projects_list` 广播给全部订阅者(`daemon.py:1518`),而这是别的会话发出的
// / `agents_list` 广播给全部订阅者(`daemon.py:1518`),而这是别的会话发出的
// 命令的回执,本连接既不认领也不该转发。
const unknownType = frame.type === undefined ? "<no-type>" : String(frame.type);
this.logger.warn(`[gui] unknown frame type=${unknownType}: ${JSON.stringify(frame).slice(0, 200)}`);
Expand Down
2 changes: 1 addition & 1 deletion emrg/gui/gui-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// path (restore on boot + sidebar UI) lands in P4 slice 2.
//
// Design notes (from the rant):
// - Open sessions = cross-project tabs (aligned with the TUI multi-open model).
// - Open sessions = cross-agent tabs (aligned with the TUI multi-open model).
// - Cap 20: never persist more than the cap; restore (slice 2) takes the most
// recent 20 by lastActive.
// - Write is atomic (.tmp + rename) so a crash mid-write never corrupts state.
Expand Down
Loading
Loading