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:
+106
-22
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user