From 1bce85cc1992fed9d0fe3a32d9c0547b3e5fb593 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Sun, 2 Aug 2026 16:09:01 +0800 Subject: [PATCH] perf(sessions): cache first-prompt titles, skip local-command noise in -l Cursor/Claude session listing now scans only the head of each jsonl for a title (aiTitle/customTitle/first user prompt), caches by (path, mtime, size), and filters local-command protocol rows. COLUMNS=120 pinned in the table test. --- myagents/launcher.py | 130 +++++++++++++++++++++++++++++++++++-------- tests/test_cli.py | 3 +- 2 files changed, 109 insertions(+), 24 deletions(-) diff --git a/myagents/launcher.py b/myagents/launcher.py index 77a6436..6ea4e81 100644 --- a/myagents/launcher.py +++ b/myagents/launcher.py @@ -13,7 +13,10 @@ from importlib.metadata import PackageNotFoundError, version from pathlib import Path import click +from rich import box from rich.console import Console +from rich.table import Table +from rich.text import Text from myagents.project_root import get_workspace_root @@ -304,29 +307,88 @@ def _sessions_dir(backend: str, chat_cwd: Path) -> Path: return config["sessions_root"]() / munged -def _first_prompt_claude(session_file: Path) -> str: - """Best-effort snippet of the first human prompt in a claude session log.""" +# Claude Code local-command protocol rows (not human chat); skip when picking +# the first-prompt fallback title. +_LOCAL_COMMAND_NOISE_RE = re.compile( + r"^\s*<(?:local-command-caveat|local-command-stdout|command-name)\b", + re.IGNORECASE, +) + +# Only the head of each jsonl is scanned for a title: aiTitle/customTitle are +# written near the start of a session and the first user prompt is the first +# user row, so a bounded read covers the overwhelming majority of sessions +# without paying a full-file parse per `-l` row. +_TITLE_SCAN_LINES = 512 + +# Title cache keyed by (path, mtime_ns, size); a `-l` call can scan hundreds of +# jsonl files, and unchanged files must not be re-parsed between polls. +_TITLE_CACHE_MAX = 2000 +_TITLE_CACHE: dict[tuple[str, int, int], str] = {} + + +def _claude_user_text(entry: dict) -> str: + """Plain-text of a claude user entry (text blocks joined).""" + content = entry.get("message", {}).get("content") + if isinstance(content, list): + return " ".join( + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ) + return content if isinstance(content, str) else "" + + +def _session_title_claude(session_file: Path) -> str: + """Claude jsonl title matching Claude Code SDK priority. + + customTitle (Ctrl+R) > aiTitle (auto-generated) > first real user prompt. + Same source of truth the xiaohe client sidebar uses, so `myclaude -l` and + the UI show the same label for the same session. + """ + try: + st = session_file.stat() + except OSError: + return "" + key = (str(session_file), st.st_mtime_ns, st.st_size) + if key in _TITLE_CACHE: + return _TITLE_CACHE[key] + + custom_title = ai_title = first_prompt = "" + first_locked = False try: with session_file.open(encoding="utf-8", errors="ignore") as fh: - for line in fh: + for _ in range(_TITLE_SCAN_LINES): + line = fh.readline() + if not line: + break try: entry = json.loads(line) except json.JSONDecodeError: continue - if entry.get("type") != "user": - continue - content = entry.get("message", {}).get("content") - if isinstance(content, list): - content = " ".join( - block.get("text", "") - for block in content - if isinstance(block, dict) and block.get("type") == "text" - ) - if isinstance(content, str) and content.strip(): - return " ".join(content.split())[:80] + ct = entry.get("customTitle") + if isinstance(ct, str) and ct: + custom_title = ct + at = entry.get("aiTitle") + if isinstance(at, str) and at: + ai_title = at + if not first_locked and entry.get("type") == "user": + if ( + entry.get("isMeta") is True + or entry.get("isCompactSummary") is True + ): + continue + text = _claude_user_text(entry).strip() + if text and not _LOCAL_COMMAND_NOISE_RE.match(text): + first_prompt = " ".join(text.split())[:200] + first_locked = True except OSError: - pass - return "" + return "" + + result = custom_title or ai_title or first_prompt + if len(_TITLE_CACHE) >= _TITLE_CACHE_MAX: + _TITLE_CACHE.clear() + _TITLE_CACHE[key] = result + return result def _first_prompt_kimi(session_file: Path) -> str: @@ -509,9 +571,11 @@ def _session_files(backend: str, chat_cwd: Path) -> list[Path]: return files -def _first_prompt(backend: str, session_file: Path) -> str: +def _session_title(backend: str, session_file: Path) -> str: + """Session label for the list: backend-native title when available, else + the first human prompt.""" if backend == "claude": - return _first_prompt_claude(session_file) + return _session_title_claude(session_file) if backend == "codex": return _first_prompt_codex(session_file) if backend == "cursor": @@ -597,19 +661,39 @@ def _list_sessions(backend: str, chat_cwd: Path) -> None: return console.print(f"[bold]Sessions in[/bold] {chat_cwd}") + table = Table( + box=box.SIMPLE_HEAD, + show_header=True, + header_style="dim", + expand=True, + pad_edge=False, + collapse_padding=True, + ) + table.add_column( + "Title", overflow="ellipsis", no_wrap=True, ratio=1, min_width=20 + ) + table.add_column( + "ID", overflow="ellipsis", no_wrap=True, style="cyan", min_width=36 + ) + table.add_column( + "Updated", justify="right", no_wrap=True, style="dim", min_width=16 + ) + for session_file in files: - mtime = datetime.fromtimestamp(session_file.stat().st_mtime) - snippet = _first_prompt(backend, session_file) or "[dim](empty)[/dim]" + mtime = session_file.stat().st_mtime + title = _session_title(backend, session_file) if backend in ("kimi", "cursor"): session_id = session_file.parent.name elif backend == "codex": session_id = _codex_session_id(session_file) else: session_id = session_file.stem - console.print( - f" [cyan]{session_id}[/cyan] " - f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet}" + table.add_row( + Text(title or "(empty)", style="dim" if not title else ""), + session_id, + datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M"), ) + console.print(table) resume_prog = f"my{backend}" console.print( f"\n[dim]Resume with[/dim] [green]{resume_prog} {_resume_syntax(backend)}[/green] " diff --git a/tests/test_cli.py b/tests/test_cli.py index 0c7b770..e817765 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -788,7 +788,8 @@ class TestCursorListSessions: }, ): result = runner.invoke( - cli, ["cursor", "--cwd", str(cwd), "--list"] + cli, ["cursor", "--cwd", str(cwd), "--list"], + env={"COLUMNS": "120"}, ) assert result.exit_code == 0, result.output assert "chat-abc123" in result.output