From 5b8f694d8be1d9f98f2619da6eb556f60f185804 Mon Sep 17 00:00:00 2001 From: argszero Date: Fri, 28 Aug 2026 06:19:42 +0800 Subject: [PATCH] emrg: auto-title new sessions from the first user message (TUI client) Borrowed from Codex rust-v0.150.0 (#40492): unnamed terminal tasks receive descriptive titles automatically. EMRG sessions displayed the raw session id until the host manually ran /rename; the on-demand LLM auto-title (empty-title /rename) already existed but was never automatic. Client-side, deterministic, zero-cost: when the first message of an untitled session is submitted, a short title is derived from the prompt text (first non-empty line, whitespace-collapsed, 30-char cap with ellipsis) and sent via the existing rename_session command. Slash commands and empty messages leave the session untitled. Adds _auto_title_from_prompt (module-level, unit-testable) + 8 tests; Agent.md count 1133 -> 1141. --- Agent.md | 2 +- emrg/client/app.py | 34 ++++++++++++++++++++++++- tests/test_app_auto_title.py | 49 ++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/test_app_auto_title.py diff --git a/Agent.md b/Agent.md index ea6ef940..9fbbe6e2 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1133) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1141) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (95: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api + 4 boot-contract + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (448: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 25 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) diff --git a/emrg/client/app.py b/emrg/client/app.py index 74ac2c0a..76e5ee43 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -4,7 +4,7 @@ from __future__ import annotations -import asyncio, json, logging, os, platform, signal, subprocess, sys, threading, time +import asyncio, json, logging, os, platform, re, signal, subprocess, sys, threading, time try: import fcntl # POSIX-only(TUI 非阻塞 stdin);Windows 无此模块 except ImportError: # pragma: no cover - Windows @@ -83,6 +83,29 @@ def _format_status_left(title: str, sid: str, model: str = "") -> str: return " ".join(parts) +def _auto_title_from_prompt(text: str, max_len: int = 30) -> str | None: + """Derive a short session title from the first user message. + + Codex #40492 borrow: unnamed tasks/sessions get a descriptive title + automatically. Deterministic text-derived (no LLM call, zero latency, + no token cost) — unlike the on-demand LLM auto-title via ``/rename`` + with an empty title. + + Returns ``None`` when the message is a slash command or has no usable + text (session stays untitled in that case). Module-level so it is + unit-testable. + """ + if not text or text.lstrip().startswith("/"): + return None + first = next((ln.strip() for ln in text.splitlines() if ln.strip()), "") + if not first: + return None + first = re.sub(r"\s+", " ", first) + if len(first) <= max_len: + return first + return first[: max_len - 1] + "…" + + # ── Clipboard image support (platform-adaptive) ───────────── def _detect_clipboard_image() -> tuple[bool, str | None]: @@ -2007,6 +2030,15 @@ def _is_image_token(s, i): _pending_images[:] = [img for img in _pending_images if img.get("label") in inp.text] images = _pending_images or None _pending_images = [] + # Auto-title a fresh, untitled session from its first user + # message (Codex #40492 borrow: unnamed sessions/tasks get a + # descriptive title automatically). Deterministic text-derived + # title — free, no LLM call. Skips slash commands. + if msg_count == 1 and not session_title: + auto_title = _auto_title_from_prompt(text) + if auto_title: + await conn.send_command("rename_session", session_id=session_id, + cwd=cwd, title=auto_title) rid = await conn.send_task(session_id=session_id, cwd=cwd, prompt=text, images=images) if was_busy: diff --git a/tests/test_app_auto_title.py b/tests/test_app_auto_title.py new file mode 100644 index 00000000..143933e2 --- /dev/null +++ b/tests/test_app_auto_title.py @@ -0,0 +1,49 @@ +"""Tests for _auto_title_from_prompt (Codex #40492 borrow). + +New sessions previously displayed the raw session id until the host ran +`/rename` manually. The auto-title helper derives a short descriptive +title from the first user message — deterministic, no LLM call. +""" + +from emrg.client.app import _auto_title_from_prompt + + +def test_plain_prompt_kept_verbatim(): + assert _auto_title_from_prompt("fix the billing bug") == "fix the billing bug" + + +def test_long_prompt_truncated_with_ellipsis(): + title = _auto_title_from_prompt("x" * 100) + assert len(title) == 30 + assert title.endswith("…") + + +def test_slash_command_skipped(): + assert _auto_title_from_prompt("/sessions") is None + assert _auto_title_from_prompt(" /rename foo bar") is None + + +def test_multiline_uses_first_line(): + assert _auto_title_from_prompt("first line\nsecond line") == "first line" + + +def test_whitespace_only_returns_none(): + assert _auto_title_from_prompt("") is None + assert _auto_title_from_prompt(" \n ") is None + + +def test_cjk_prompt_truncates_safely(): + text = "请修复这个 bug,它发生在登录流程中,需要检查 token 刷新逻辑" + title = _auto_title_from_prompt(text) + assert isinstance(title, str) + assert len(title) == 30 + assert title.endswith("…") + + +def test_collapses_internal_whitespace(): + assert _auto_title_from_prompt(" hello world ") == "hello world" + + +def test_max_len_custom(): + assert len(_auto_title_from_prompt("a" * 50, max_len=12)) == 12 + assert _auto_title_from_prompt("short", max_len=12) == "short"