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:
+107
-23
@@ -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] "
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user