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
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."""
try:
with session_file.open(encoding="utf-8", errors="ignore") as fh:
for line in fh:
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if entry.get("type") != "user":
continue
# 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):
content = " ".join(
return " ".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]
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:
pass
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:
@@ -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] "
+2 -1
View File
@@ -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