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.
This commit is contained in:
Zhengshou Lai
2026-08-02 16:09:01 +08:00
parent ffac0ba37d
commit 1bce85cc19
2 changed files with 109 additions and 24 deletions
+106 -22
View File
@@ -13,7 +13,10 @@ from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
import click import click
from rich import box
from rich.console import Console from rich.console import Console
from rich.table import Table
from rich.text import Text
from myagents.project_root import get_workspace_root 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 return config["sessions_root"]() / munged
def _first_prompt_claude(session_file: Path) -> str: # Claude Code local-command protocol rows (not human chat); skip when picking
"""Best-effort snippet of the first human prompt in a claude session log.""" # the first-prompt fallback title.
try: _LOCAL_COMMAND_NOISE_RE = re.compile(
with session_file.open(encoding="utf-8", errors="ignore") as fh: r"^\s*<(?:local-command-caveat|local-command-stdout|command-name)\b",
for line in fh: re.IGNORECASE,
try: )
entry = json.loads(line)
except json.JSONDecodeError: # Only the head of each jsonl is scanned for a title: aiTitle/customTitle are
continue # written near the start of a session and the first user prompt is the first
if entry.get("type") != "user": # user row, so a bounded read covers the overwhelming majority of sessions
continue # 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") content = entry.get("message", {}).get("content")
if isinstance(content, list): if isinstance(content, list):
content = " ".join( return " ".join(
block.get("text", "") block.get("text", "")
for block in content for block in content
if isinstance(block, dict) and block.get("type") == "text" if isinstance(block, dict) and block.get("type") == "text"
) )
if isinstance(content, str) and content.strip(): return content if isinstance(content, str) else ""
return " ".join(content.split())[:80]
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: except OSError:
pass
return "" 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 _ in range(_TITLE_SCAN_LINES):
line = fh.readline()
if not line:
break
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
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:
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: def _first_prompt_kimi(session_file: Path) -> str:
@@ -509,9 +571,11 @@ def _session_files(backend: str, chat_cwd: Path) -> list[Path]:
return files 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": if backend == "claude":
return _first_prompt_claude(session_file) return _session_title_claude(session_file)
if backend == "codex": if backend == "codex":
return _first_prompt_codex(session_file) return _first_prompt_codex(session_file)
if backend == "cursor": if backend == "cursor":
@@ -597,19 +661,39 @@ def _list_sessions(backend: str, chat_cwd: Path) -> None:
return return
console.print(f"[bold]Sessions in[/bold] {chat_cwd}") 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: for session_file in files:
mtime = datetime.fromtimestamp(session_file.stat().st_mtime) mtime = session_file.stat().st_mtime
snippet = _first_prompt(backend, session_file) or "[dim](empty)[/dim]" title = _session_title(backend, session_file)
if backend in ("kimi", "cursor"): if backend in ("kimi", "cursor"):
session_id = session_file.parent.name session_id = session_file.parent.name
elif backend == "codex": elif backend == "codex":
session_id = _codex_session_id(session_file) session_id = _codex_session_id(session_file)
else: else:
session_id = session_file.stem session_id = session_file.stem
console.print( table.add_row(
f" [cyan]{session_id}[/cyan] " Text(title or "(empty)", style="dim" if not title else ""),
f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet}" session_id,
datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M"),
) )
console.print(table)
resume_prog = f"my{backend}" resume_prog = f"my{backend}"
console.print( console.print(
f"\n[dim]Resume with[/dim] [green]{resume_prog} {_resume_syntax(backend)}[/green] " f"\n[dim]Resume with[/dim] [green]{resume_prog} {_resume_syntax(backend)}[/green] "
+2 -1
View File
@@ -788,7 +788,8 @@ class TestCursorListSessions:
}, },
): ):
result = runner.invoke( 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 result.exit_code == 0, result.output
assert "chat-abc123" in result.output assert "chat-abc123" in result.output