Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 39 additions & 15 deletions src/lecode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 <ref>`` / ``-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 <ref>`` / ``-c/--continue``
reopen an existing session and keep its name.
"""
from rich.console import Console

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -971,6 +994,7 @@ def callback(
continue_last=continue_last,
no_color=no_color,
worktree=worktree,
name=name,
)
)
if prompt == _STDIN_MARKER:
Expand Down
3 changes: 2 additions & 1 deletion src/lecode/session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +20,7 @@
"SessionNotFoundError",
"SessionStore",
"auto_name",
"sanitize_title",
"unique_name",
"validate_name",
]
19 changes: 19 additions & 0 deletions src/lecode/session/naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
54 changes: 44 additions & 10 deletions src/lecode/session/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,28 @@ 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."""

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:
Expand Down Expand Up @@ -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)
Expand All @@ -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."""
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down
34 changes: 34 additions & 0 deletions src/lecode/session/title.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 12 additions & 8 deletions src/lecode/slash/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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}")

Expand Down Expand Up @@ -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}")
Expand Down
Loading
Loading