From 71e092dbc81128f6be1fb8008854db446aca4f01 Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Tue, 8 Sep 2026 09:02:04 +0400 Subject: [PATCH] feat: title new sessions from their first message Interactive startup and bare /new no longer prompt for a name: the session starts under a timestamp and one background request titles it after the first user message, using the selected provider and model. --name names a session up front; /rename always wins over a pending title. Storage listings now apply the latest rename event so titles show up in session lists, resume-by-name, and name deduplication. --- src/lecode/cli.py | 54 +++++++--- src/lecode/session/__init__.py | 3 +- src/lecode/session/naming.py | 19 ++++ src/lecode/session/storage.py | 54 ++++++++-- src/lecode/session/title.py | 34 ++++++ src/lecode/slash/handlers.py | 20 ++-- src/lecode/tui/app.py | 34 ++++++ tests/test_session_naming.py | 29 +++++- tests/test_session_storage.py | 16 +++ tests/test_session_title.py | 46 ++++++++ tests/test_setup_wizard.py | 17 ++- tests/test_slash_session.py | 22 ++-- tests/test_tui_app.py | 185 +++++++++++++++++++++++++++------ tests/test_tui_loading.py | 6 -- tests/test_worktree.py | 4 +- 15 files changed, 453 insertions(+), 90 deletions(-) create mode 100644 src/lecode/session/title.py create mode 100644 tests/test_session_title.py diff --git a/src/lecode/cli.py b/src/lecode/cli.py index 01ab456..9432136 100644 --- a/src/lecode/cli.py +++ b/src/lecode/cli.py @@ -4,8 +4,9 @@ mode (``-p/--prompt``: auto-approved tools, auto-named session, final response on stdout, token/cost summary on stderr, exit codes 0 done / 1 error / 2 startup / 3 max turns), and the interactive TUI (default when -no ``-p`` is given): session-name prompt → session on disk → chat, with -``-r/--resume`` and ``-c/--continue`` reopening existing sessions. +no ``-p`` is given): session on disk immediately (timestamp-named, AI-titled +from the first message, or ``--name``), then chat, with ``-r/--resume`` and +``-c/--continue`` reopening existing sessions. """ from __future__ import annotations @@ -47,7 +48,7 @@ from lecode.providers.live import LoadedCatalog, load_catalog from lecode.providers.openai_compat import ChatClient from lecode.providers.types import ChatMessage -from lecode.session.naming import auto_name +from lecode.session.naming import auto_name, unique_name, validate_name from lecode.session.storage import ( AmbiguousSessionError, SessionInUseError, @@ -57,7 +58,6 @@ from lecode.setup_wizard import offer_first_run_setup, run_wizard from lecode.telemetry import init_telemetry, shutdown_telemetry from lecode.tui.app import TuiApp -from lecode.tui.name_prompt import prompt_session_name #: Exit codes (headless mode uses the same taxonomy). EXIT_OK = 0 @@ -587,15 +587,16 @@ def run_interactive( continue_last: bool = False, no_color: bool = False, worktree: str | None = None, + name: str | None = None, ) -> int: """Interactive TUI path: banner → progressive load report → chat. Startup order (locked): dependency check (done by the caller) → ASCII banner (printed immediately) → first-run setup offer (no config + tty) - → config load → session-name prompt. Ctrl-C/Ctrl-D at the prompt exits - 0 before any session file is created. Each subsystem prints its loading - line as it finishes. ``-r/--resume `` / ``-c/--continue`` reopen an - existing session and keep its name (no prompt). + → config load → session on disk. ``--name`` names the session up front; + without it the session starts under a timestamp name and the TUI titles + it from the first message. ``-r/--resume `` / ``-c/--continue`` + reopen an existing session and keep its name. """ from rich.console import Console @@ -668,13 +669,10 @@ def run_interactive( return EXIT_STARTUP session = store.open(meta.id) else: - try: - name = asyncio.run(prompt_session_name(store)) - except KeyboardInterrupt: - return EXIT_OK - if name is None: - return EXIT_OK - session = store.create(name, cwd, model=config.llm.model) + if name is not None: + session = store.create(unique_name(name, store), cwd, model=config.llm.model) + else: + session = store.create(auto_name(store), cwd, model=config.llm.model, auto_title=True) # One live lecode per session: refuse to attach when another process # holds the session lock (fail fast, before any network startup work). @@ -915,6 +913,14 @@ def callback( str | None, typer.Option("--chain", help="Run a brainstorm→plan→code→review chain on TOPIC."), ] = None, + name: Annotated[ + str | None, + typer.Option( + "--name", + help="Name the session up front (interactive mode). " + "Without it the session is titled from your first message.", + ), + ] = None, ) -> None: """lecode — minimalist terminal AI coding agent.""" if setup: @@ -925,6 +931,23 @@ def callback( if sum(x is not None for x in (prompt, loop, chain)) > 1: typer.echo("error: --prompt, --loop and --chain are mutually exclusive", err=True) raise typer.Exit(EXIT_STARTUP) + if name is not None: + if (error := validate_name(name)) is not None: + typer.echo(f"error: invalid session name: {error}", err=True) + raise typer.Exit(EXIT_STARTUP) + if ( + prompt is not None + or loop is not None + or chain is not None + or resume is not None + or continue_last + ): + typer.echo( + "error: --name cannot be combined with --prompt, --loop, --chain, " + "--resume or --continue", + err=True, + ) + raise typer.Exit(EXIT_STARTUP) if loop is not None: raise typer.Exit( run_loop_mode( @@ -971,6 +994,7 @@ def callback( continue_last=continue_last, no_color=no_color, worktree=worktree, + name=name, ) ) if prompt == _STDIN_MARKER: diff --git a/src/lecode/session/__init__.py b/src/lecode/session/__init__.py index 6c4c47e..ff247a8 100644 --- a/src/lecode/session/__init__.py +++ b/src/lecode/session/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from lecode.session.naming import auto_name, unique_name, validate_name +from lecode.session.naming import auto_name, sanitize_title, unique_name, validate_name from lecode.session.storage import ( AmbiguousSessionError, Session, @@ -20,6 +20,7 @@ "SessionNotFoundError", "SessionStore", "auto_name", + "sanitize_title", "unique_name", "validate_name", ] diff --git a/src/lecode/session/naming.py b/src/lecode/session/naming.py index 5f97ee7..d278ccf 100644 --- a/src/lecode/session/naming.py +++ b/src/lecode/session/naming.py @@ -60,3 +60,22 @@ def auto_name(store: SessionStore | None = None) -> str: if store is None: return base return unique_name(base, store) + + +def sanitize_title(raw: str | None) -> str | None: + """Coerce raw LLM output into a usable session name, or ``None``. + + Takes the first line, strips surrounding whitespace and quotes, cuts at + the name length cap, then :func:`validate_name`; unusable output (empty, + leading dot, separators, control chars) yields ``None`` so callers keep + the fallback name. + """ + if not raw: + return None + first = next((line.strip() for line in raw.splitlines() if line.strip()), "") + text = first.strip("'\"").rstrip(".") + if len(text) > MAX_NAME_LENGTH: + text = text[:MAX_NAME_LENGTH].strip() + if validate_name(text) is not None: + return None + return text diff --git a/src/lecode/session/storage.py b/src/lecode/session/storage.py index b47a562..1c7beca 100644 --- a/src/lecode/session/storage.py +++ b/src/lecode/session/storage.py @@ -98,6 +98,18 @@ def _next_seq(records: list[Record]) -> int: ) +def _latest_rename_name(records: list[Record]) -> str | None: + """The last rename event's name, or ``None`` if the session was never renamed.""" + for record in reversed(records): + if ( + isinstance(record, EventRecord) + and record.kind == "rename" + and (name := record.data.get("name")) + ): + return str(name) + return None + + @dataclass class Session: """Handle for an open session file.""" @@ -105,6 +117,9 @@ class Session: meta: MetaRecord path: Path next_seq: int = 1 + #: Transient: this process auto-named the session and may auto-title it + #: from the first user message. Never persisted; explicit names stay. + auto_title: bool = False @property def id(self) -> str: @@ -137,6 +152,8 @@ def create( cwd: str | Path, model: str | None = None, agent: str = "build", + *, + auto_title: bool = False, ) -> Session: """Create a new session file with its meta line.""" self.sessions_dir.mkdir(parents=True, exist_ok=True) @@ -150,7 +167,7 @@ def create( ) path = self.sessions_dir / f"{meta.id}.jsonl" path.write_text(meta.model_dump_json() + "\n", encoding="utf-8") - return Session(meta=meta, path=path, next_seq=1) + return Session(meta=meta, path=path, next_seq=1, auto_title=auto_title) def open(self, session_id: str) -> Session: """Open an existing session by id; the latest rename event wins.""" @@ -161,9 +178,8 @@ def open(self, session_id: str) -> Session: meta = next((r for r in records if isinstance(r, MetaRecord)), None) if meta is None: raise SessionNotFoundError(f"{session_id} (no meta record)") - renames = [r for r in records if isinstance(r, EventRecord) and r.kind == "rename"] - if renames and (new_name := renames[-1].data.get("name")): - meta = meta.model_copy(update={"name": str(new_name)}) + if (new_name := _latest_rename_name(records)) is not None: + meta = meta.model_copy(update={"name": new_name}) return Session(meta=meta, path=path, next_seq=_next_seq(records)) def acquire_lock(self, session: Session) -> SessionLock | None: @@ -249,6 +265,15 @@ def append_event( self._append(session, record) return record + def rename(self, session: Session, name: str) -> EventRecord: + """Append a rename event and update the live handle's name in one step. + + Callers are responsible for validating/deduplicating ``name``. + """ + record = self.append_event(session, "rename", {"name": name}) + session.meta.name = name + return record + def append_tombstone(self, session: Session, up_to_seq: int) -> TombstoneRecord: record = TombstoneRecord(seq=session.next_seq, ts=_now(), up_to_seq=up_to_seq) self._append(session, record) @@ -276,23 +301,32 @@ def read_records(self, session: Session) -> list[Record]: def list_sessions(self, cwd: Path | str | None = None) -> list[MetaRecord]: """All sessions' meta records, most recent first. - ``cwd`` scopes the listing to sessions created in that folder — - resume never crosses directories. + The latest ``rename`` event's name wins, so listings, name-based + resume and deduplication all see renames. ``cwd`` scopes the listing + to sessions created in that folder, resume never crosses directories. """ if not self.sessions_dir.is_dir(): return [] metas: list[MetaRecord] = [] for path in self.sessions_dir.glob("*.jsonl"): + meta: MetaRecord | None = None + records: list[Record] = [] with path.open(encoding="utf-8") as f: for line in f: record = parse_record(line) - if isinstance(record, MetaRecord): - metas.append(record) - break if record is None: self.corrupt_lines += 1 continue - break # first valid record is not meta: skip file + if meta is None: + if not isinstance(record, MetaRecord): + break # first valid record is not meta: skip file + meta = record + records.append(record) + if meta is None: + continue + if (renamed := _latest_rename_name(records)) is not None: + meta = meta.model_copy(update={"name": renamed}) + metas.append(meta) if cwd is not None: wanted = str(cwd) metas = [m for m in metas if m.cwd == wanted] diff --git a/src/lecode/session/title.py b/src/lecode/session/title.py new file mode 100644 index 0000000..ec2e55b --- /dev/null +++ b/src/lecode/session/title.py @@ -0,0 +1,34 @@ +"""AI-generated session titles. + +One non-blocking provider request turns the first user message of an +auto-named session into a short title; failures and unusable output yield +``None`` so callers keep the fallback name. No retries, no extra config. +""" + +from __future__ import annotations + +from typing import Any + +from lecode.session.naming import sanitize_title + +TITLE_PROMPT = ( + "Write a short title for a chat session with an AI coding agent, based on " + "the user's first message. Reply with only the title: 3-6 words, " + "descriptive, in the same language as the request. No quotes, no trailing " + "period, no explanation." +) + + +async def generate_title(provider: Any, model: str, text: str) -> str | None: + """Ask ``provider`` for a session title for ``text``; ``None`` on failure.""" + try: + completed = await provider.complete( + [ + {"role": "system", "content": TITLE_PROMPT}, + {"role": "user", "content": text}, + ], + model=model, + ) + except Exception: + return None + return sanitize_title(completed.content) diff --git a/src/lecode/slash/handlers.py b/src/lecode/slash/handlers.py index d598a86..3334fea 100644 --- a/src/lecode/slash/handlers.py +++ b/src/lecode/slash/handlers.py @@ -28,7 +28,7 @@ ModelNotFoundError, ) from lecode.session.handoff import handoff as handoff_session -from lecode.session.naming import unique_name, validate_name +from lecode.session.naming import auto_name, unique_name, validate_name from lecode.session.stats import session_stats from lecode.session.storage import AmbiguousSessionError, SessionNotFoundError from lecode.slash.catalog import BUILTIN_COMMANDS @@ -122,7 +122,9 @@ async def _ask_name(app: TuiApp) -> str | None: async def cmd_new(app: TuiApp, args: list[str]) -> None: - """``/new [name]``: start a fresh session and switch to it.""" + """``/new [name]``: start a fresh session and switch to it. Without a + name the session starts under a timestamp and is AI-titled from its + first message (like a nameless interactive start).""" if _busy(app): return if args: @@ -131,11 +133,13 @@ async def cmd_new(app: TuiApp, args: list[str]) -> None: app.feed.error(error) return name = unique_name(wanted, app.store) + auto_title = False else: - name = await _ask_name(app) - if name is None: - return - session = app.store.create(name, app.runtime.ctx.cwd, model=app.config.llm.model) + name = auto_name(app.store) + auto_title = True + session = app.store.create( + name, app.runtime.ctx.cwd, model=app.config.llm.model, auto_title=auto_title + ) if app.switch_session(session): app.feed.info(f"new session: {session.name}") @@ -302,9 +306,9 @@ async def cmd_rename(app: TuiApp, args: list[str]) -> None: if wanted.strip() == app.session.name: app.feed.info(f"already named: {app.session.name}") return + app.session.auto_title = False # explicit rename beats any pending AI title name = unique_name(wanted, app.store) - app.store.append_event(app.session, "rename", {"name": name}) - app.session.meta.name = name + app.store.rename(app.session, name) app.status.session_name = name app.refresh() app.feed.info(f"renamed to: {name}") diff --git a/src/lecode/tui/app.py b/src/lecode/tui/app.py index 362bd7c..b3040de 100644 --- a/src/lecode/tui/app.py +++ b/src/lecode/tui/app.py @@ -79,7 +79,9 @@ ) from lecode.providers.openai_compat import ProviderError from lecode.providers.types import ContentPart +from lecode.session.naming import unique_name from lecode.session.stats import session_stats +from lecode.session.title import generate_title from lecode.slash.handlers import build_registry from lecode.slash.registry import AmbiguousCommandError, CommandRegistry, UnknownCommandError from lecode.tui.clipboard import copy_to_clipboard @@ -244,6 +246,8 @@ def __init__( self._status.context_window = self.catalog.get(config.llm.model).context_window self._turn_task: asyncio.Task[None] | None = None + #: Background auto-title request for an auto-named session. + self._title_task: asyncio.Task[None] | None = None #: Pierre feedback stashed from the event stream; rendered after the #: stats line at the end of the turn. self._pending_review: Review | None = None @@ -709,6 +713,10 @@ async def run(self, *, input: Input | None = None, output: Output | None = None) self.cancel_turn() if self._turn_task is not None: await asyncio.gather(self._turn_task, return_exceptions=True) + if self._title_task is not None: + self._title_task.cancel() + await asyncio.gather(self._title_task, return_exceptions=True) + self._title_task = None if self._mcp_task is not None: await asyncio.gather(self._mcp_task, return_exceptions=True) self._mcp_task = None @@ -1028,6 +1036,12 @@ async def _run_turn(self, text: MessageContent, *, overlay: str | None = None) - message: dict[str, Any] = {"role": "user", "content": text} self._history.append(message) self._store.append_message(self._session, message) + if self._session.auto_title and self._title_task is None: + # Untitled auto-named session: one background request titles it + # from this first message. Never blocks the turn. + self._title_task = asyncio.ensure_future( + self._auto_title(self._session, describe_content(text)) + ) # Live context growth: the submitted message joins the next prompt. self._status.context_used += self._estimate(describe_content(text)) run_history = self._history @@ -1087,6 +1101,26 @@ async def _run_turn(self, text: MessageContent, *, overlay: str | None = None) - self._feed.user_message(describe_content(follow_up)) self._turn_task = asyncio.ensure_future(self._run_turn(follow_up)) + async def _auto_title(self, session: Session, text: str) -> None: + """Title an auto-named session from its first message (background). + + A ``/rename`` or session switch clears ``session.auto_title`` and the + result is discarded, so explicit names always win. + """ + try: + title = await generate_title(self._runner.provider, self._runner.model, text) + if title is None or self._quit or not session.auto_title: + return + session.auto_title = False + self._store.rename(session, unique_name(title, self._store)) + if self._session is session: + self._status.session_name = session.name + self._invalidate() + finally: + # The task is done: a later auto-named session (bare /new) must + # be able to schedule its own title. + self._title_task = None + async def _run_subagent_turn(self, name: str, prompt: str) -> None: """A direct ``@agent`` submission: the subagent answers as a side query — the exchange is not persisted to the session.""" diff --git a/tests/test_session_naming.py b/tests/test_session_naming.py index 5d5d10f..10c74c8 100644 --- a/tests/test_session_naming.py +++ b/tests/test_session_naming.py @@ -6,7 +6,13 @@ import pytest -from lecode.session import SessionStore, auto_name, unique_name, validate_name +from lecode.session import ( + SessionStore, + auto_name, + sanitize_title, + unique_name, + validate_name, +) @pytest.fixture @@ -67,3 +73,24 @@ def test_auto_name_format(): def test_auto_name_collision_suffixes(store): store.create(auto_name(), cwd="/tmp") assert auto_name(store) == auto_name() + "-2" + + +def test_sanitize_title_keeps_plain_titles(): + assert sanitize_title("Fix login bug") == "Fix login bug" + + +def test_sanitize_title_takes_first_line_and_strips(): + assert sanitize_title("Fix login bug.\n\nlonger explanation here.") == "Fix login bug" + assert sanitize_title(' "Session titles" ') == "Session titles" + + +def test_sanitize_title_truncates_to_max_length(): + title = sanitize_title("A long and winding title " * 5) + assert title is not None + assert len(title) <= 64 + assert validate_name(title or "") is None + + +@pytest.mark.parametrize("raw", ["", " ", "\n\n", ".hidden", "bad/title", "tab\there"]) +def test_sanitize_title_rejects_unusable_output(raw): + assert sanitize_title(raw) is None diff --git a/tests/test_session_storage.py b/tests/test_session_storage.py index 211a8d3..a032741 100644 --- a/tests/test_session_storage.py +++ b/tests/test_session_storage.py @@ -114,6 +114,22 @@ def test_resolve_ambiguous_prefix_raises(store): assert b.id != a.id +def test_list_sessions_uses_latest_rename(store, session): + store.append_event(session, "rename", {"name": "first-take"}) + store.append_event(session, "rename", {"name": "final-name"}) + metas = store.list_sessions() + assert metas[0].name == "final-name" + # the id/cwd survive the rename; only the name changed + assert metas[0].id == session.id + + +def test_resolve_uses_renamed_name(store, session): + store.append_event(session, "rename", {"name": "final-name"}) + assert store.resolve("final-name").id == session.id + with pytest.raises(SessionNotFoundError): + store.resolve("demo") + + def test_list_sessions_scoped_to_folder(store): store.create("here", cwd="/tmp/here") store.create("there", cwd="/tmp/there") diff --git a/tests/test_session_title.py b/tests/test_session_title.py new file mode 100644 index 0000000..99a0655 --- /dev/null +++ b/tests/test_session_title.py @@ -0,0 +1,46 @@ +"""Tests for AI-generated session titles (first user message → short title).""" + +from __future__ import annotations + +from typing import Any + +from lecode.providers.types import CompletedMessage +from lecode.session.title import generate_title + + +class TitleFake: + """Provider stub answering only ``complete`` calls.""" + + def __init__(self, content: str | None = "Fix login bug", fail: bool = False) -> None: + self.content = content + self.fail = fail + self.requests: list[tuple[list[dict], str]] = [] + + async def complete(self, messages: list[dict], model: str, **kwargs: Any) -> CompletedMessage: + self.requests.append((messages, model)) + if self.fail: + raise RuntimeError("provider down") + return CompletedMessage(content=self.content or "") + + +async def test_generate_title_returns_sanitized_title(): + fake = TitleFake(content="Fix login bug.\n\nmore context ignored.") + assert await generate_title(fake, "openai/gpt-5-mini", "help me fix the login bug") == ( + "Fix login bug" + ) + messages, model = fake.requests[0] + assert model == "openai/gpt-5-mini" + assert messages[0]["role"] == "system" + assert "3-6 words" in str(messages[0]["content"]) + assert messages[1] == {"role": "user", "content": "help me fix the login bug"} + + +async def test_generate_title_provider_failure_returns_none(): + fake = TitleFake(fail=True) + assert await generate_title(fake, "m", "prompt") is None + + +async def test_generate_title_unusable_output_returns_none(): + fake = TitleFake(content=".dotfile") + assert await generate_title(fake, "m", "prompt") is None + assert await generate_title(TitleFake(content=""), "m", "prompt") is None diff --git a/tests/test_setup_wizard.py b/tests/test_setup_wizard.py index 3d59353..1d54a92 100644 --- a/tests/test_setup_wizard.py +++ b/tests/test_setup_wizard.py @@ -308,17 +308,26 @@ async def test_offer_eof_means_no(monkeypatch): def _patch_past_offer(monkeypatch, offered): - """Fake the setup offer and stop run_interactive at the name prompt.""" + """Fake the setup offer and stop run_interactive right before the TUI.""" async def fake_offer(): offered.append(True) return False - async def no_name(store, **kwargs): - return None # Ctrl-D at the name prompt → exit 0 + class FakeTui: + def __init__(self, *args, **kwargs): + pass + + def set_catalog(self, *args, **kwargs): + pass + + async def fake_run_tui(tui, client, background=None): + return 0 monkeypatch.setattr("lecode.cli.offer_first_run_setup", fake_offer) - monkeypatch.setattr("lecode.cli.prompt_session_name", no_name) + monkeypatch.setattr("lecode.cli.TuiApp", FakeTui) + monkeypatch.setattr("lecode.cli._run_tui", fake_run_tui) + monkeypatch.setattr("lecode.cli.build_provider", lambda config, api_key=None: object()) def test_interactive_first_run_offers_setup(tmp_path, monkeypatch): diff --git a/tests/test_slash_session.py b/tests/test_slash_session.py index 2919d45..e824d1a 100644 --- a/tests/test_slash_session.py +++ b/tests/test_slash_session.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re + from tests.test_tui_app import make_app, make_blocking_app, wait_for from lecode.session.model import EventRecord @@ -32,6 +34,7 @@ async def test_new_with_name_switches_session(tmp_path, monkeypatch): await app.handle_command("/new second-session") assert app.session.name == "second-session" assert app.session.id != old_id + assert app.session.auto_title is False # an explicit name is never AI-titled assert app.runner.session is app.session assert app.runtime.ctx.session is app.session assert app.status.session_name == "second-session" @@ -52,19 +55,16 @@ async def test_new_invalid_name_errors(tmp_path, monkeypatch): assert app.session.name == "test-session" -async def test_new_without_name_prompts(tmp_path, monkeypatch): - app, _, _ = make_app(tmp_path, monkeypatch, []) - monkeypatch.setattr("lecode.tui.name_prompt.prompt_session_name", _fake_name_prompt("prompted")) - await app.handle_command("/new") - assert app.session.name == "prompted" - - -async def test_new_prompt_aborted_keeps_session(tmp_path, monkeypatch): +async def test_new_without_name_auto_names(tmp_path, monkeypatch): + """Bare /new starts immediately: timestamp name, AI title from the first + message (no name prompt).""" app, _, out = make_app(tmp_path, monkeypatch, []) - monkeypatch.setattr("lecode.tui.name_prompt.prompt_session_name", _fake_name_prompt(None)) + old_id = app.session.id await app.handle_command("/new") - assert app.session.name == "test-session" - assert "cancelled" in out.getvalue() + assert re.fullmatch(r"session-\d{8}-\d{6}", app.session.name) + assert app.session.id != old_id + assert app.session.auto_title is True + assert "new session:" in out.getvalue() async def test_new_refused_while_turn_runs(tmp_path, monkeypatch): diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index d24529d..cc37a3b 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import re from io import StringIO from typing import Any, ClassVar @@ -16,7 +17,7 @@ from lecode.agent.builder import build_runtime from lecode.cli import app as cli_app from lecode.config.models import Config -from lecode.providers.types import Done, TokenDelta +from lecode.providers.types import CompletedMessage, Done, TokenDelta from lecode.session.storage import SessionStore from lecode.tui.app import QUEUE_LIMIT, TuiApp from lecode.tui.statusline import StatusLineState @@ -74,6 +75,38 @@ async def wait_for(cond, timeout=5.0): raise AssertionError("condition not met within timeout") +class TitleProvider: + """Answers turns with a short reply and titles via ``complete``.""" + + def __init__(self, title: str | None = "Fix login bug") -> None: + self.title = title + self.title_requests: list[list[dict]] = [] + + def stream_chat(self, messages, model, tools=None, **kwargs): + async def _stream(): + yield TokenDelta(text="ok") + yield Done(finish_reason="stop") + + return _stream() + + async def complete(self, messages, model, **kwargs): + self.title_requests.append([dict(m) for m in messages]) + return CompletedMessage(content=self.title or "") + + +class BlockingTitleProvider(TitleProvider): + """Title generation blocks until ``release`` is set.""" + + def __init__(self, title: str) -> None: + super().__init__(title) + self.release = asyncio.Event() + + async def complete(self, messages, model, **kwargs): + self.title_requests.append([dict(m) for m in messages]) + await self.release.wait() + return CompletedMessage(content=self.title or "") + + # -- submissions / runner wiring -------------------------------------------- @@ -148,6 +181,86 @@ async def test_submit_streams_answer(tmp_path, monkeypatch): assert provider.requests[0]["messages"][-1] == {"role": "user", "content": "hi"} +async def test_first_message_auto_titles_auto_named_session(tmp_path, monkeypatch): + """An auto-named session gets an AI title after its first user message, + persisted as a rename so listings and the statusline agree.""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + app._session.auto_title = True + title_provider = TitleProvider("Fix login bug") + app._runner.provider = title_provider + await app._submit("help me fix the login bug") + await app._turn_task + await wait_for(lambda: app.session.name == "Fix login bug") + assert app.session.name == "Fix login bug" + assert app.status.session_name == "Fix login bug" + assert app.session.auto_title is False + assert title_provider.title_requests[0][1] == { + "role": "user", + "content": "help me fix the login bug", + } + # persisted: reopen and listing both see the title + assert app.store.open(app.session.id).meta.name == "Fix login bug" + assert [m.name for m in app.store.list_sessions()] == ["Fix login bug"] + + +async def test_explicit_session_name_never_auto_titled(tmp_path, monkeypatch): + app, _, _ = make_app(tmp_path, monkeypatch, []) + title_provider = TitleProvider("Sneaky rename") + app._runner.provider = title_provider + await app._submit("hello") + await app._turn_task + assert title_provider.title_requests == [] + assert app.session.name == "test-session" + + +async def test_rename_beats_pending_auto_title(tmp_path, monkeypatch): + """A `/rename` while the title is generating must win.""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + app._session.auto_title = True + title_provider = BlockingTitleProvider("AI title") + app._runner.provider = title_provider + await app._submit("build a thing") + await app._turn_task + await wait_for(lambda: bool(title_provider.title_requests)) + title_task = app._title_task # still blocked: the handle is stable + await app.handle_command("/rename my choice") + title_provider.release.set() + await title_task + assert app.session.name == "my choice" + assert app.store.open(app.session.id).meta.name == "my choice" + + +async def test_unusable_ai_title_keeps_fallback_name(tmp_path, monkeypatch): + app, _, _ = make_app(tmp_path, monkeypatch, []) + app._session.auto_title = True + fallback = app.session.name + app._runner.provider = TitleProvider(".bad") + await app._submit("hello") + await app._turn_task + await wait_for(lambda: app._title_task is None) # attempt finished, title rejected + assert app.session.name == fallback + assert [m.name for m in app.store.list_sessions()] == [fallback] + + +async def test_each_auto_named_session_gets_a_title(tmp_path, monkeypatch): + """A second bare /new in the same process must title too (and dedupe + against the first session's title, which lives in a rename event).""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + app._runner.provider = TitleProvider("Same title") + await app.handle_command("/new") + first_id = app.session.id + await app._submit("build one") + await app._turn_task + await wait_for(lambda: app.session.name == "Same title") + assert app.session.name == "Same title" + await app.handle_command("/new") + assert app.session.id != first_id + await app._submit("build two") + await app._turn_task + await wait_for(lambda: app.session.name == "Same title-2") + assert app.session.name == "Same title-2" + + def test_set_catalog_binds_late(tmp_path, monkeypatch): """The background catalog fetch lands after the chat opened: runner, ctx, and the statusline context window all rebind, and the feed announces it.""" @@ -485,46 +598,65 @@ def cli_env(tmp_path, monkeypatch): return tmp_path -def _name_prompt(value): - async def _prompt(store, **kwargs): - return value +def test_cli_default_creates_auto_named_session(cli_env): + """No --name: the session starts immediately with a timestamp name and + auto-titling armed (the AI title lands after the first message).""" + result = runner.invoke(cli_app, []) + assert result.exit_code == 0 + assert len(FakeTui.instances) == 1 + session = FakeTui.instances[0].session + assert re.fullmatch(r"session-\d{8}-\d{6}", session.name) + assert session.auto_title is True + assert [m.name for m in SessionStore().list_sessions()] == [session.name] - return _prompt +def test_cli_name_flag_creates_explicit_session(cli_env): + result = runner.invoke(cli_app, ["--name", "Fix login"]) + assert result.exit_code == 0 + session = FakeTui.instances[0].session + assert session.name == "Fix login" + assert session.auto_title is False + assert [m.name for m in SessionStore().list_sessions()] == ["Fix login"] -def test_cli_abort_exits_zero_without_session(cli_env, monkeypatch): - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt(None)) - result = runner.invoke(cli_app, []) + +def test_cli_name_flag_deduplicates(cli_env): + SessionStore().create("Fix login", cli_env) + result = runner.invoke(cli_app, ["--name", "Fix login"]) assert result.exit_code == 0 + assert FakeTui.instances[0].session.name == "Fix login-2" + + +@pytest.mark.parametrize( + "extra", + [["-p", "hi"], ["--loop", "plan.md"], ["--chain", "topic"], ["-r"], ["-r", "old"], ["-c"]], +) +def test_cli_name_rejected_with_other_modes(cli_env, extra): + result = runner.invoke(cli_app, ["--name", "nope", *extra]) + assert result.exit_code == 2 + assert "--name cannot be combined" in result.output assert FakeTui.instances == [] - assert SessionStore().list_sessions() == [] -def test_cli_interactive_creates_named_session(cli_env, monkeypatch): - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt("chatty")) - result = runner.invoke(cli_app, []) - assert result.exit_code == 0 - assert len(FakeTui.instances) == 1 - assert FakeTui.instances[0].session.name == "chatty" - assert [m.name for m in SessionStore().list_sessions()] == ["chatty"] +def test_cli_invalid_name_rejected(cli_env): + result = runner.invoke(cli_app, ["--name", ".hidden"]) + assert result.exit_code == 2 + assert "invalid session name" in result.output + assert FakeTui.instances == [] -def test_cli_default_mode_is_yolo(cli_env, monkeypatch): - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt("s")) +def test_cli_default_mode_is_yolo(cli_env): result = runner.invoke(cli_app, []) assert result.exit_code == 0 assert FakeTui.instances[0].runtime.ctx.permission_checker.mode == "yolo" -def test_cli_safe_flag_forces_readonly(cli_env, monkeypatch): - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt("s")) +def test_cli_safe_flag_forces_readonly(cli_env): result = runner.invoke(cli_app, ["--safe"]) assert result.exit_code == 0 assert FakeTui.instances[0].runtime.ctx.permission_checker.mode == "readonly" -def test_cli_read_only_alias_still_works(cli_env, monkeypatch): - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt("s")) +def test_cli_read_only_alias_still_works(cli_env): result = runner.invoke(cli_app, ["--read-only"]) assert result.exit_code == 0 assert FakeTui.instances[0].runtime.ctx.permission_checker.mode == "readonly" @@ -532,11 +664,6 @@ def test_cli_read_only_alias_still_works(cli_env, monkeypatch): def test_cli_resume_keeps_name_without_prompt(cli_env, monkeypatch): SessionStore().create("old-session", cli_env) - - async def _boom(store, **kwargs): - raise AssertionError("name prompt must not run on --resume") - - monkeypatch.setattr("lecode.cli.prompt_session_name", _boom) result = runner.invoke(cli_app, ["-r", "old-session"]) assert result.exit_code == 0 assert FakeTui.instances[0].session.name == "old-session" @@ -546,14 +673,12 @@ def test_cli_continue_picks_latest(cli_env, monkeypatch): store = SessionStore() store.create("first", cli_env) store.create("second", cli_env) - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt(None)) result = runner.invoke(cli_app, ["-c"]) assert result.exit_code == 0 assert FakeTui.instances[0].session.name == "second" def test_cli_resume_unknown_ref_fails(cli_env, monkeypatch): - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt(None)) result = runner.invoke(cli_app, ["-r", "nope"]) assert result.exit_code == 2 assert "nope" in result.output @@ -585,7 +710,6 @@ async def _pick(store, cwd, **kwargs): def test_cli_no_color_lands_in_config(cli_env, monkeypatch): - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt("x")) result = runner.invoke(cli_app, ["--no-color"]) assert result.exit_code == 0 assert FakeTui.instances[0].config.ui.no_color is True @@ -676,7 +800,6 @@ def test_cli_resume_locked_session_fails(cli_env, monkeypatch): session = store.create("busy", cli_env) lock = store.acquire_lock(session) assert lock is not None - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt(None)) result = runner.invoke(cli_app, ["-r", "busy"]) assert result.exit_code == 2 assert "already open in another lecode process" in result.output diff --git a/tests/test_tui_loading.py b/tests/test_tui_loading.py index d161035..e514d97 100644 --- a/tests/test_tui_loading.py +++ b/tests/test_tui_loading.py @@ -323,7 +323,6 @@ def test_interactive_startup_prints_loading_screen(env, monkeypatch, capsys): """run_interactive prints the banner and step lines before the chat.""" import lecode.cli as cli - monkeypatch.setattr(cli, "prompt_session_name", _fake_name_prompt) monkeypatch.setattr(cli, "build_provider", lambda config, api_key=None: object()) monkeypatch.setattr(cli, "TuiApp", _FakeTui) monkeypatch.setattr(cli, "_run_tui", _fake_run_tui) @@ -337,10 +336,6 @@ def test_interactive_startup_prints_loading_screen(env, monkeypatch, capsys): assert out.index("| | ___") < out.index("provider") # banner before the steps -async def _fake_name_prompt(store): - return "loading-test" - - class _FakeTui: def __init__(self, *args, **kwargs): pass @@ -358,7 +353,6 @@ def test_catalog_fetch_runs_in_background(env, monkeypatch): from lecode.providers.live import LoadedCatalog events: list[str] = [] - monkeypatch.setattr(cli, "prompt_session_name", _fake_name_prompt) monkeypatch.setattr(cli, "build_provider", lambda config, api_key=None: object()) class FakeTui: diff --git a/tests/test_worktree.py b/tests/test_worktree.py index 169dd55..fa3177f 100644 --- a/tests/test_worktree.py +++ b/tests/test_worktree.py @@ -11,7 +11,7 @@ from os.path import realpath import pytest -from tests.test_tui_app import FakeTui, _name_prompt, make_app +from tests.test_tui_app import FakeTui, make_app from typer.testing import CliRunner from lecode.cli import app as cli_app @@ -304,7 +304,6 @@ def cli_env(tmp_path, monkeypatch): def test_cli_worktree_flag_switches_cwd(cli_env, monkeypatch): make_repo_sync(cli_env) - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt("wt-session")) result = runner.invoke(cli_app, ["--worktree", "feat"]) assert result.exit_code == 0, result.output expected = cli_env / ".lecode" / "worktrees" / "feat" @@ -316,7 +315,6 @@ def test_cli_worktree_flag_switches_cwd(cli_env, monkeypatch): def test_cli_worktree_flag_not_a_repo(cli_env, monkeypatch): - monkeypatch.setattr("lecode.cli.prompt_session_name", _name_prompt("x")) result = runner.invoke(cli_app, ["--worktree", "feat"]) assert result.exit_code == 2 assert "not a git repository" in result.output