Coverage for src/lilbee/cli/sessions.py: 100%
82 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""CLI for listing and managing saved chat sessions."""
3from __future__ import annotations
5from dataclasses import asdict
6from pathlib import Path
7from typing import NoReturn
9import typer
10from rich.table import Table
12from lilbee.cli import theme
13from lilbee.cli.app import apply_overrides, console, data_dir_option, global_option
14from lilbee.cli.helpers import json_output
15from lilbee.core.config import cfg
16from lilbee.sessions import (
17 SESSIONS_DISABLED_HINT,
18 SessionStore,
19 TitleSource,
20 sessions_enabled,
21)
23sessions_app = typer.Typer(
24 name="sessions",
25 help="List and manage saved chat sessions.",
26 no_args_is_help=True,
27)
29_yes_option = typer.Option(False, "--yes", "-y", help="Skip the delete confirmation.")
30_id_argument = typer.Argument(..., help="Session id, or a unique prefix of it.")
33def _require_sessions() -> None:
34 """Report that sessions are off and exit; every command reaches the store
35 through ``_store``, so the check lives there rather than in each command.
36 """
37 if sessions_enabled():
38 return
39 if cfg.json_mode:
40 json_output({"error": SESSIONS_DISABLED_HINT})
41 else:
42 console.print(SESSIONS_DISABLED_HINT)
43 raise typer.Exit(0)
46def _store() -> SessionStore:
47 _require_sessions()
48 return SessionStore()
51def _fail(message: str) -> NoReturn:
52 if cfg.json_mode:
53 json_output({"error": message})
54 else:
55 console.print(f"[{theme.ERROR}]{message}[/{theme.ERROR}]")
56 raise typer.Exit(1)
59def _resolve_id(prefix: str) -> str:
60 """Resolve a full id or unique prefix to a session id, or exit 1."""
61 matches = [meta.id for meta in _store().list() if meta.id.startswith(prefix)]
62 if len(matches) == 1:
63 return matches[0]
64 if not matches:
65 _fail(f"No session matching {prefix!r}.")
66 _fail(f"Prefix {prefix!r} is ambiguous ({len(matches)} sessions match).")
69@sessions_app.command("list")
70def list_cmd(
71 data_dir: Path | None = data_dir_option,
72 use_global: bool = global_option,
73) -> None:
74 """List saved conversations, newest first."""
75 apply_overrides(data_dir=data_dir, use_global=use_global)
76 metas = _store().list()
77 if cfg.json_mode:
78 json_output({"sessions": [asdict(meta) for meta in metas]})
79 return
80 if not metas:
81 console.print("No saved sessions.")
82 return
83 table = Table(box=None, pad_edge=False)
84 for column in ("ID", "Title", "Msgs", "Model", "Origin", "Updated"):
85 table.add_column(column, justify="right" if column == "Msgs" else "left")
86 for meta in metas:
87 table.add_row(
88 meta.id[:8],
89 meta.title,
90 str(meta.message_count),
91 meta.model_ref,
92 meta.origin.value,
93 meta.updated_at[:19],
94 )
95 console.print(table)
98@sessions_app.command("show")
99def show_cmd(
100 session_id: str = _id_argument,
101 data_dir: Path | None = data_dir_option,
102 use_global: bool = global_option,
103) -> None:
104 """Print a saved conversation's transcript."""
105 apply_overrides(data_dir=data_dir, use_global=use_global)
106 session = _store().get(_resolve_id(session_id))
107 if cfg.json_mode:
108 json_output(
109 {
110 "meta": asdict(session.meta),
111 "messages": [
112 {"role": m.role.value, "content": m.content, "sources": list(m.sources)}
113 for m in session.messages
114 ],
115 # What compaction folded older turns into (empty if never
116 # compacted). A script that resumes from this JSON needs it, or
117 # it rebuilds history without what was already condensed -- the
118 # same hole the HTTP and MCP surfaces used to have.
119 "summary": session.summary,
120 }
121 )
122 return
123 console.print(f"[{theme.ACCENT}]{session.meta.title}[/{theme.ACCENT}]")
124 for message in session.messages:
125 console.print(f"[bold]{message.role.value}[/bold]: {message.content}")
128@sessions_app.command("rename")
129def rename_cmd(
130 session_id: str = _id_argument,
131 title: str = typer.Argument(..., help="The new title."),
132 data_dir: Path | None = data_dir_option,
133 use_global: bool = global_option,
134) -> None:
135 """Rename a saved conversation."""
136 apply_overrides(data_dir=data_dir, use_global=use_global)
137 resolved = _resolve_id(session_id)
138 _store().set_title(resolved, title, TitleSource.CUSTOM)
139 if cfg.json_mode:
140 json_output({"id": resolved, "title": title})
141 return
142 console.print(f"Renamed to [{theme.ACCENT}]{title}[/{theme.ACCENT}].")
145@sessions_app.command("delete")
146def delete_cmd(
147 session_id: str = _id_argument,
148 yes: bool = _yes_option,
149 data_dir: Path | None = data_dir_option,
150 use_global: bool = global_option,
151) -> None:
152 """Delete a saved conversation."""
153 apply_overrides(data_dir=data_dir, use_global=use_global)
154 resolved = _resolve_id(session_id)
155 if (
156 not yes
157 and not cfg.json_mode
158 and not typer.confirm(f"Delete {resolved[:8]}?", default=False)
159 ):
160 raise typer.Abort()
161 _store().delete(resolved)
162 if cfg.json_mode:
163 json_output({"id": resolved, "deleted": True})
164 return
165 console.print(f"Deleted [{theme.ACCENT}]{resolved[:8]}[/{theme.ACCENT}].")