Coverage for src/lilbee/cli/sessions.py: 100%

120 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +0000

1"""CLI for listing and managing saved chat sessions.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import asdict 

6from pathlib import Path 

7from typing import NoReturn 

8 

9import typer 

10from rich.table import Table 

11from rich.text import Text 

12 

13from lilbee.app.session_export import session_markdown, write_session_markdown 

14from lilbee.cli import theme 

15from lilbee.cli.app import apply_overrides, console, data_dir_option, global_option 

16from lilbee.cli.helpers import json_output 

17from lilbee.core.config import cfg 

18from lilbee.runtime.console import styled 

19from lilbee.sessions import ( 

20 SESSIONS_DISABLED_HINT, 

21 SessionForkRangeError, 

22 SessionOrigin, 

23 SessionOwnershipError, 

24 SessionStore, 

25 TitleSource, 

26 sessions_enabled, 

27) 

28 

29sessions_app = typer.Typer( 

30 name="sessions", 

31 help="List and manage saved chat sessions.", 

32 no_args_is_help=True, 

33) 

34 

35_yes_option = typer.Option(False, "--yes", "-y", help="Skip the delete confirmation.") 

36_id_argument = typer.Argument(..., help="Session id, or a unique prefix of it.") 

37_messages_option = typer.Option( 

38 None, "--messages", help="Copy only the first N messages (default: all of them)." 

39) 

40_output_option = typer.Option( 

41 None, 

42 "--output", 

43 "-o", 

44 help="Write to this file, or into this directory, instead of printing to stdout.", 

45) 

46 

47 

48def _require_sessions() -> None: 

49 """Report that sessions are off and exit; every command reaches the store 

50 through ``_store``, so the check lives there rather than in each command. 

51 """ 

52 if sessions_enabled(): 

53 return 

54 if cfg.json_mode: 

55 json_output({"error": SESSIONS_DISABLED_HINT}) 

56 else: 

57 console.print(SESSIONS_DISABLED_HINT) 

58 raise typer.Exit(0) 

59 

60 

61def _store() -> SessionStore: 

62 _require_sessions() 

63 return SessionStore() 

64 

65 

66def _fail(message: str) -> NoReturn: 

67 if cfg.json_mode: 

68 json_output({"error": message}) 

69 else: 

70 # Text, not markup: messages carry user paths and prefixes verbatim. 

71 console.print(Text(message, style=theme.ERROR), soft_wrap=True) 

72 raise typer.Exit(1) 

73 

74 

75def _resolve_id(prefix: str) -> str: 

76 """Resolve a full id or unique prefix to a session id, or exit 1.""" 

77 matches = [meta.id for meta in _store().list() if meta.id.startswith(prefix)] 

78 if len(matches) == 1: 

79 return matches[0] 

80 if not matches: 

81 _fail(f"No session matching {prefix!r}.") 

82 _fail(f"Prefix {prefix!r} is ambiguous ({len(matches)} sessions match).") 

83 

84 

85@sessions_app.command("list") 

86def list_cmd( 

87 data_dir: Path | None = data_dir_option, 

88 use_global: bool = global_option, 

89) -> None: 

90 """List saved conversations, newest first.""" 

91 apply_overrides(data_dir=data_dir, use_global=use_global) 

92 metas = _store().list() 

93 if cfg.json_mode: 

94 json_output({"sessions": [asdict(meta) for meta in metas]}) 

95 return 

96 if not metas: 

97 console.print("No saved sessions.") 

98 return 

99 table = Table(box=None, pad_edge=False) 

100 for column in ("ID", "Title", "Msgs", "Model", "Origin", "Updated"): 

101 table.add_column(column, justify="right" if column == "Msgs" else "left") 

102 for meta in metas: 

103 table.add_row( 

104 meta.id[:8], 

105 Text(meta.title), 

106 str(meta.message_count), 

107 Text(meta.model_ref), 

108 meta.origin.value, 

109 meta.updated_at[:19], 

110 ) 

111 console.print(table) 

112 

113 

114@sessions_app.command("show") 

115def show_cmd( 

116 session_id: str = _id_argument, 

117 data_dir: Path | None = data_dir_option, 

118 use_global: bool = global_option, 

119) -> None: 

120 """Print a saved conversation's transcript.""" 

121 apply_overrides(data_dir=data_dir, use_global=use_global) 

122 session = _store().get(_resolve_id(session_id)) 

123 if cfg.json_mode: 

124 json_output( 

125 { 

126 "meta": asdict(session.meta), 

127 "messages": [ 

128 {"role": m.role.value, "content": m.content, "sources": list(m.sources)} 

129 for m in session.messages 

130 ], 

131 # What compaction folded older turns into (empty if never 

132 # compacted). A script that resumes from this JSON needs it, or 

133 # it rebuilds history without what was already condensed -- the 

134 # same hole the HTTP and MCP surfaces used to have. 

135 "summary": session.summary, 

136 } 

137 ) 

138 return 

139 console.print(Text(session.meta.title, style=theme.ACCENT), soft_wrap=True) 

140 for message in session.messages: 

141 console.print( 

142 Text.assemble((message.role.value, "bold"), ": ", message.content), soft_wrap=True 

143 ) 

144 

145 

146@sessions_app.command("fork") 

147def fork_cmd( 

148 session_id: str = _id_argument, 

149 messages: int | None = _messages_option, 

150 data_dir: Path | None = data_dir_option, 

151 use_global: bool = global_option, 

152) -> None: 

153 """Start a new conversation from a copy of a saved one.""" 

154 apply_overrides(data_dir=data_dir, use_global=use_global) 

155 store = _store() 

156 try: 

157 fork_id = store.fork( 

158 _resolve_id(session_id), message_count=messages, origin=SessionOrigin.CLI 

159 ) 

160 except (SessionOwnershipError, SessionForkRangeError) as exc: 

161 _fail(str(exc)) 

162 meta = store.get(fork_id).meta 

163 if cfg.json_mode: 

164 json_output({"meta": asdict(meta)}) 

165 return 

166 console.print( 

167 Text.assemble("Forked to ", (meta.title, theme.ACCENT), f" ({fork_id[:8]})."), 

168 soft_wrap=True, 

169 ) 

170 

171 

172@sessions_app.command("export") 

173def export_cmd( 

174 session_id: str = _id_argument, 

175 output: str | None = _output_option, 

176 data_dir: Path | None = data_dir_option, 

177 use_global: bool = global_option, 

178) -> None: 

179 """Export a saved conversation as markdown.""" 

180 apply_overrides(data_dir=data_dir, use_global=use_global) 

181 resolved = _resolve_id(session_id) 

182 session = _store().get(resolved) 

183 if output is None: 

184 markdown = session_markdown(session) 

185 if cfg.json_mode: 

186 json_output({"id": resolved, "markdown": markdown}) 

187 return 

188 # Bytes go to the binary stream, so a redirect is UTF-8 on every platform. 

189 typer.echo(markdown.encode("utf-8"), nl=False) 

190 return 

191 try: 

192 path = write_session_markdown(session, output) 

193 except OSError as exc: 

194 _fail(f"Could not write the export: {exc}") 

195 if cfg.json_mode: 

196 json_output({"id": resolved, "path": str(path)}) 

197 return 

198 # A Text, not markup: a Windows separator before "[" would read as an escape. 

199 # soft_wrap keeps a long path on one line, so it copies whole. 

200 console.print(Text.assemble("Exported to ", (str(path), theme.ACCENT), "."), soft_wrap=True) 

201 

202 

203@sessions_app.command("rename") 

204def rename_cmd( 

205 session_id: str = _id_argument, 

206 title: str = typer.Argument(..., help="The new title."), 

207 data_dir: Path | None = data_dir_option, 

208 use_global: bool = global_option, 

209) -> None: 

210 """Rename a saved conversation.""" 

211 apply_overrides(data_dir=data_dir, use_global=use_global) 

212 resolved = _resolve_id(session_id) 

213 _store().set_title(resolved, title, TitleSource.CUSTOM) 

214 if cfg.json_mode: 

215 json_output({"id": resolved, "title": title}) 

216 return 

217 console.print(Text.assemble("Renamed to ", (title, theme.ACCENT), "."), soft_wrap=True) 

218 

219 

220@sessions_app.command("delete") 

221def delete_cmd( 

222 session_id: str = _id_argument, 

223 yes: bool = _yes_option, 

224 data_dir: Path | None = data_dir_option, 

225 use_global: bool = global_option, 

226) -> None: 

227 """Delete a saved conversation.""" 

228 apply_overrides(data_dir=data_dir, use_global=use_global) 

229 resolved = _resolve_id(session_id) 

230 if ( 

231 not yes 

232 and not cfg.json_mode 

233 and not typer.confirm(f"Delete {resolved[:8]}?", default=False) 

234 ): 

235 raise typer.Abort() 

236 _store().delete(resolved) 

237 if cfg.json_mode: 

238 json_output({"id": resolved, "deleted": True}) 

239 return 

240 console.print(styled("Deleted ", (resolved[:8], theme.ACCENT), "."))