convo-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. convo/__init__.py +3 -0
  2. convo/__main__.py +110 -0
  3. convo/bootstrap.py +273 -0
  4. convo/core/__init__.py +1 -0
  5. convo/core/config.py +53 -0
  6. convo/core/contracts.py +160 -0
  7. convo/core/exceptions.py +5 -0
  8. convo/db/__init__.py +1 -0
  9. convo/db/base.py +13 -0
  10. convo/db/chunks.py +132 -0
  11. convo/db/conversations.py +167 -0
  12. convo/db/documents.py +34 -0
  13. convo/db/models.py +81 -0
  14. convo/db/session.py +27 -0
  15. convo/llm/__init__.py +0 -0
  16. convo/llm/auth.py +504 -0
  17. convo/llm/client.py +207 -0
  18. convo/llm/credentials.py +192 -0
  19. convo/llm/models.py +71 -0
  20. convo/llm/streaming.py +62 -0
  21. convo/migrations/env.py +43 -0
  22. convo/migrations/script.py.mako +22 -0
  23. convo/migrations/versions/5c3b75f9c5c1_describe_your_schema_change.py +22 -0
  24. convo/migrations/versions/732cf62e465c_create_rag_tables.py +49 -0
  25. convo/migrations/versions/README.md +3 -0
  26. convo/migrations/versions/a42e9d8f0b31_hybrid_search_and_chat_sessions.py +66 -0
  27. convo/pipeline/__init__.py +0 -0
  28. convo/pipeline/chat.py +129 -0
  29. convo/pipeline/chunking.py +42 -0
  30. convo/pipeline/embeddings.py +83 -0
  31. convo/pipeline/index.py +160 -0
  32. convo/pipeline/ingestion.py +46 -0
  33. convo/pipeline/reranking.py +63 -0
  34. convo/pipeline/routing.py +64 -0
  35. convo/pipeline/sessions.py +40 -0
  36. convo/pipeline/worker.py +226 -0
  37. convo/prompts/__init__.py +0 -0
  38. convo/prompts/answers.py +97 -0
  39. convo/prompts/schema.py +97 -0
  40. convo/ui/__init__.py +1 -0
  41. convo/ui/app.py +752 -0
  42. convo/ui/commands.py +37 -0
  43. convo/ui/files.py +110 -0
  44. convo/ui/models.py +84 -0
  45. convo/ui/preview.py +20 -0
  46. convo/ui/sessions.py +97 -0
  47. convo/ui/theme.tcss +64 -0
  48. convo/ui/thinking.py +70 -0
  49. convo_cli-0.1.0.dist-info/METADATA +160 -0
  50. convo_cli-0.1.0.dist-info/RECORD +53 -0
  51. convo_cli-0.1.0.dist-info/WHEEL +4 -0
  52. convo_cli-0.1.0.dist-info/entry_points.txt +2 -0
  53. convo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
convo/ui/app.py ADDED
@@ -0,0 +1,752 @@
1
+ import asyncio
2
+ import os
3
+ from pathlib import Path
4
+ from typing import ClassVar
5
+
6
+ from rich.console import Group
7
+ from rich.markdown import Markdown
8
+ from rich.text import Text
9
+ from textual import work
10
+ from textual.app import App, ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal, Vertical, VerticalScroll
13
+ from textual.events import Resize
14
+ from textual.geometry import Size
15
+ from textual.widgets import Input, OptionList, Static
16
+ from textual.widgets.option_list import Option
17
+
18
+ from convo import __version__
19
+ from convo.core.contracts import (
20
+ REASONING_EFFORTS,
21
+ AccountControls,
22
+ Backend,
23
+ Document,
24
+ GenerationControls,
25
+ GenerationSelection,
26
+ Message,
27
+ PreparationControls,
28
+ ReasoningEffort,
29
+ SessionControls,
30
+ )
31
+ from convo.core.exceptions import BackendNotConnected
32
+ from convo.pipeline.sessions import ChatSession
33
+ from convo.ui.commands import COMMANDS, Command, CommandMenu, matching_commands
34
+ from convo.ui.files import FilePicker
35
+ from convo.ui.models import ModelPicker
36
+ from convo.ui.sessions import SessionPicker
37
+ from convo.ui.thinking import GOLD, ThinkingPicker, thinking_key
38
+
39
+ WORDMARK = r""" ___ ___ _ ____ _____
40
+ / __/ _ \| '_ \ \ / / _ \
41
+ | (_| (_) | | | \ V / (_) |
42
+ \___\___/|_| |_|\_/ \___/"""
43
+
44
+
45
+ class ConvoApp(App[None]):
46
+ TITLE = "convo"
47
+ CSS_PATH = "theme.tcss"
48
+ BINDINGS: ClassVar = [
49
+ Binding("ctrl+c", "quit", "Quit", priority=True),
50
+ Binding("ctrl+o", "file", "Choose file"),
51
+ Binding("up", "command_previous", show=False, priority=True),
52
+ Binding("down", "command_next", show=False, priority=True),
53
+ Binding("tab", "complete_command", show=False, priority=True),
54
+ Binding("escape", "cancel_or_dismiss", show=False, priority=True),
55
+ Binding("shift+tab", "cycle_thinking", "Thinking", priority=True),
56
+ Binding("ctrl+t", "cycle_thinking", "Thinking", priority=True, id="thinking-cycle"),
57
+ Binding("f2", "choose_thinking", "Choose thinking", priority=True),
58
+ Binding("pageup", "history_up", "Read earlier messages", priority=True),
59
+ Binding("pagedown", "history_down", "Read later messages", priority=True),
60
+ ]
61
+
62
+ def __init__(
63
+ self, directory: Path, backend: Backend, *, thinking_shortcut: str | None = None
64
+ ) -> None:
65
+ super().__init__()
66
+ self.thinking_shortcut = thinking_key(
67
+ thinking_shortcut or os.environ.get("CONVO_THINKING_KEY", "ctrl+t")
68
+ )
69
+ self.set_keymap({"thinking-cycle": self.thinking_shortcut})
70
+ self.directory = directory.resolve()
71
+ self.backend = backend
72
+ self.session = ChatSession()
73
+ self.busy = False
74
+ self.preparing_path: Path | None = None
75
+ self._cancel_prepare = False
76
+ self.selection = GenerationSelection()
77
+ self.command_choices: tuple[Command, ...] = ()
78
+ self._chat_started = False
79
+
80
+ @property
81
+ def document(self) -> Document | None:
82
+ return self.session.document
83
+
84
+ @property
85
+ def history(self) -> tuple[Message, ...]:
86
+ return self.session.history
87
+
88
+ def compose(self) -> ComposeResult:
89
+ with Horizontal(id="masthead"):
90
+ yield Static(Text(WORDMARK, style=f"bold {GOLD}"), id="brand")
91
+ with Vertical(id="identity"):
92
+ yield Static(f"CONVO / v{__version__}", id="version")
93
+ yield Static("Your files. A quiet conversation.", id="tagline")
94
+ yield Static(
95
+ Text(str(self.directory), no_wrap=True, overflow="ellipsis"), id="directory"
96
+ )
97
+ with VerticalScroll(id="conversation"):
98
+ yield Static(
99
+ Text.assemble(
100
+ ("Start a conversation\n\n", "bold #dddddd"),
101
+ ("/file", GOLD),
102
+ (" choose a document\n", "#888888"),
103
+ ("/sessions", GOLD),
104
+ (" pick up where you left off\n", "#888888"),
105
+ ("/login", GOLD),
106
+ (" connect your ChatGPT account", "#888888"),
107
+ ),
108
+ id="welcome",
109
+ )
110
+ yield CommandMenu(id="command-menu")
111
+ with Vertical(id="composer"):
112
+ with Vertical(id="input-panel"):
113
+ yield Input(placeholder="Ask about your file, or / for commands", id="prompt")
114
+ with Horizontal(id="settings"):
115
+ yield Static("", id="selection", markup=False)
116
+ yield Static("", id="thinking", markup=False)
117
+ yield Static("", id="status", markup=False)
118
+ yield Static("", id="keys")
119
+
120
+ def on_mount(self) -> None:
121
+ self.sync_layout()
122
+ if isinstance(self.backend, GenerationControls):
123
+ try:
124
+ self.selection = self.backend.generation_selection()
125
+ except BackendNotConnected:
126
+ pass
127
+ self.update_status()
128
+ self.query_one("#prompt", Input).focus()
129
+ # Restore local preferences immediately. Fetch the account catalog on
130
+ # /models, login, or generation; startup must not wait for the network.
131
+
132
+ def on_resize(self, event: Resize) -> None:
133
+ self.sync_layout(event.size)
134
+ menus = self.screen_stack[0].query("#command-menu")
135
+ if menus and self.command_choices:
136
+ menus.first(CommandMenu).styles.height = self.command_menu_height(event.size)
137
+
138
+ def sync_layout(self, size: Size | None = None) -> None:
139
+ size = size or self.size
140
+ compact = size.width < 70 or size.height < 24
141
+ main_screen = self.screen_stack[0]
142
+ main_screen.set_class(compact, "compact")
143
+ main_screen.set_class(self._chat_started, "chatting")
144
+ if main_screen.query("#brand"):
145
+ main_screen.query_one("#brand", Static).update(
146
+ Text.assemble(("convo", f"bold {GOLD}"), (f" v{__version__}", "#777777"))
147
+ if compact or self._chat_started
148
+ else Text(WORDMARK, style=f"bold {GOLD}")
149
+ )
150
+ self.update_key_hints(size)
151
+
152
+ def command_menu_height(self, size: Size | None = None) -> int:
153
+ size = size or self.size
154
+ compact = size.width < 70 or size.height < 24
155
+ overhead = 9 if compact else 16 if self._chat_started else 21
156
+ return min(len(self.command_choices) + 2, 8, max(3, size.height - overhead))
157
+
158
+ def update_key_hints(self, size: Size | None = None) -> None:
159
+ size = size or self.size
160
+ if self.preparing_path is not None and self.busy:
161
+ hint = "esc cancel file preparation · ctrl+c quit"
162
+ elif size.width < 70 or size.height < 24:
163
+ hint = f"enter send · / commands · {self.thinking_shortcut} think"
164
+ else:
165
+ hint = f"enter send · / commands · {self.thinking_shortcut} think · f2 levels · ctrl+c quit"
166
+ self.screen_stack[0].query_one("#keys", Static).update(
167
+ Text(hint, no_wrap=True, overflow="ellipsis")
168
+ )
169
+
170
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
171
+ if action == "cancel_or_dismiss":
172
+ if len(self.screen_stack) != 1:
173
+ return False
174
+ return self.preparing_path is not None or (
175
+ not self.busy and self.query_one("#command-menu", CommandMenu).display
176
+ )
177
+ menu_actions = {"command_previous", "command_next", "complete_command", "dismiss_commands"}
178
+ if action in menu_actions or action in {"cycle_thinking", "choose_thinking"}:
179
+ if not self.query("#prompt") or len(self.screen_stack) != 1 or self.busy:
180
+ return False
181
+ if not self.query_one("#prompt", Input).has_focus:
182
+ return False
183
+ if action in menu_actions:
184
+ return self.query_one("#command-menu", CommandMenu).display
185
+ if action in {"history_up", "history_down"}:
186
+ return len(self.screen_stack) == 1
187
+ return super().check_action(action, parameters)
188
+
189
+ def update_status(self, detail: str = "") -> None:
190
+ model = self.selection.model
191
+ label = (
192
+ model.name
193
+ if model
194
+ else "choose a model"
195
+ if self.selection.connected
196
+ else "not connected"
197
+ )
198
+ thinking = self.selection.reasoning_effort or "auto"
199
+ self.query_one("#selection", Static).update(
200
+ Text.assemble(
201
+ ("ChatGPT · ", "#888888"), (label, GOLD), no_wrap=True, overflow="ellipsis"
202
+ )
203
+ )
204
+ self.query_one("#thinking", Static).update(
205
+ Text.assemble(("thinking ", "#888888"), (thinking, GOLD))
206
+ )
207
+ filename = (
208
+ self.preparing_path.name
209
+ if self.preparing_path
210
+ else self.document.path.name
211
+ if self.document
212
+ else "no file selected"
213
+ )
214
+ state = detail or (
215
+ "ready"
216
+ if self.document and self.document.indexed
217
+ else "shell preview"
218
+ if self.document
219
+ else "choose a file with /file"
220
+ )
221
+ self.query_one("#status", Static).update(
222
+ Text(f"{filename} · {state}", no_wrap=True, overflow="ellipsis")
223
+ )
224
+
225
+ def update_command_menu(self, text: str) -> None:
226
+ menu = self.query_one("#command-menu", CommandMenu)
227
+ self.command_choices = matching_commands(text) if not self.busy else ()
228
+ menu.clear_options()
229
+ for command in self.command_choices:
230
+ menu.add_option(
231
+ Option(
232
+ Text.assemble(
233
+ (f"{command.name:<11}", f"bold {GOLD}"), (command.description, "#888888")
234
+ ),
235
+ id=command.name,
236
+ )
237
+ )
238
+ menu.display = bool(self.command_choices)
239
+ if self.command_choices:
240
+ menu.styles.height = self.command_menu_height()
241
+ menu.highlighted = 0
242
+
243
+ def on_input_changed(self, event: Input.Changed) -> None:
244
+ if event.input.id == "prompt":
245
+ self.update_command_menu(event.value)
246
+
247
+ def action_command_previous(self) -> None:
248
+ self.query_one("#command-menu", CommandMenu).action_cursor_up()
249
+
250
+ def action_command_next(self) -> None:
251
+ self.query_one("#command-menu", CommandMenu).action_cursor_down()
252
+
253
+ def action_complete_command(self) -> None:
254
+ menu = self.query_one("#command-menu", CommandMenu)
255
+ if menu.highlighted is not None and self.command_choices:
256
+ prompt = self.query_one("#prompt", Input)
257
+ prompt.value = self.command_choices[menu.highlighted].name
258
+ prompt.cursor_position = len(prompt.value)
259
+
260
+ def action_dismiss_commands(self) -> None:
261
+ self.query_one("#command-menu", CommandMenu).display = False
262
+
263
+ async def action_cancel_or_dismiss(self) -> None:
264
+ if self.preparing_path is not None:
265
+ self._cancel_prepare = True
266
+ workers = self.workers.cancel_group(self, "backend")
267
+ await asyncio.gather(*(worker.wait() for worker in workers), return_exceptions=True)
268
+ # A worker cancelled before its coroutine starts cannot execute its
269
+ # finally block. Restore the input in that case as well.
270
+ if self.preparing_path is not None:
271
+ self.preparing_path = None
272
+ self.set_busy(False)
273
+ await self.append_message(
274
+ "convo", "File preparation cancelled. Choose a file with /file to retry."
275
+ )
276
+ else:
277
+ self.action_dismiss_commands()
278
+
279
+ async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
280
+ if event.option_list.id != "command-menu" or self.busy:
281
+ return
282
+ event.stop()
283
+ if event.option.id:
284
+ self.query_one("#prompt", Input).value = ""
285
+ self.action_dismiss_commands()
286
+ await self.dispatch_text(event.option.id)
287
+
288
+ async def append_message(self, role: str, text: str) -> None:
289
+ welcome = self.query("#welcome")
290
+ if welcome:
291
+ await welcome.first().remove()
292
+ if role in {"you", "chatgpt"} and not self._chat_started:
293
+ self._chat_started = True
294
+ self.sync_layout()
295
+ body: Text | Group
296
+ if role == "you":
297
+ body = Text(text, style="#eeeeee")
298
+ message_class = "user-message"
299
+ elif role == "chatgpt":
300
+ body = Group(
301
+ Text("convo", style=f"bold {GOLD}"),
302
+ Text(""),
303
+ Markdown(text, style="#dddddd", code_theme="monokai"),
304
+ )
305
+ message_class = "assistant-message"
306
+ else:
307
+ message_class = (
308
+ "source-message"
309
+ if role == "sources"
310
+ else "error-message"
311
+ if role == "error"
312
+ else "notice-message"
313
+ )
314
+ body = Text.assemble(
315
+ (role + "\n", "bold #e49b85" if role == "error" else f"bold {GOLD}"),
316
+ (text, "#aaaaaa" if role == "sources" else "#bbbbbb"),
317
+ )
318
+ chat = self.query_one("#conversation", VerticalScroll)
319
+ await chat.mount(Static(body, classes=f"message {message_class}"))
320
+ chat.scroll_end(animate=False)
321
+
322
+ async def clear_conversation(self) -> None:
323
+ await self.query_one("#conversation", VerticalScroll).remove_children()
324
+ self._chat_started = False
325
+ self.sync_layout()
326
+
327
+ def reveal_latest_turn(self) -> None:
328
+ questions = list(self.query(".user-message"))
329
+ if questions:
330
+ # Wait for answer/source layout, then show the question and the
331
+ # beginning of the answer instead of jumping to its last citation.
332
+ self.call_after_refresh(questions[-1].scroll_visible, top=True, animate=False)
333
+
334
+ def action_history_up(self) -> None:
335
+ self.query_one("#conversation", VerticalScroll).scroll_page_up(animate=False)
336
+
337
+ def action_history_down(self) -> None:
338
+ self.query_one("#conversation", VerticalScroll).scroll_page_down(animate=False)
339
+
340
+ def set_busy(self, busy: bool, detail: str = "") -> None:
341
+ self.busy = busy
342
+ prompt = self.query_one("#prompt", Input)
343
+ prompt.disabled = busy
344
+ self.update_key_hints()
345
+ self.update_command_menu(prompt.value)
346
+ self.update_status(detail)
347
+ if not busy and len(self.screen_stack) == 1:
348
+ prompt.focus()
349
+
350
+ def action_file(self) -> None:
351
+ if not self.busy and len(self.screen_stack) == 1:
352
+ self.action_dismiss_commands()
353
+ self.push_screen(FilePicker(self.directory), self.file_chosen)
354
+
355
+ def file_chosen(self, path: Path | None) -> None:
356
+ if path is not None:
357
+ self.preparing_path = path
358
+ self._cancel_prepare = False
359
+ self.set_busy(True, "starting file worker… · Esc cancel")
360
+ self.prepare_file(path)
361
+
362
+ @work(group="backend", exclusive=True)
363
+ async def prepare_file(self, path: Path) -> None:
364
+ try:
365
+ if isinstance(self.backend, PreparationControls):
366
+ document = await self.backend.prepare_with_progress(path, self.update_status)
367
+ else:
368
+ document = await self.backend.prepare(path)
369
+ if isinstance(self.backend, SessionControls):
370
+ snapshot = await self.backend.new_session(document)
371
+ self.session.restore(snapshot)
372
+ else:
373
+ self.session.select_document(document)
374
+ await self.clear_conversation()
375
+ suffix = (
376
+ "Ask a question to begin." if document.indexed else "Shell preview · file selected."
377
+ )
378
+ await self.append_message("file", f"{path.name}\n{suffix}")
379
+ except asyncio.CancelledError:
380
+ if self._cancel_prepare:
381
+ await self.append_message(
382
+ "convo", "File preparation cancelled. Choose a file with /file to retry."
383
+ )
384
+ raise
385
+ except BackendNotConnected as error:
386
+ await self.append_message("convo", str(error))
387
+ except Exception:
388
+ await self.append_message(
389
+ "error",
390
+ "Could not prepare this file. Check PostgreSQL is running and the file is readable (up to 10 MB).",
391
+ )
392
+ finally:
393
+ self.preparing_path = None
394
+ self.set_busy(False)
395
+
396
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
397
+ if event.input.id != "prompt":
398
+ return
399
+ event.stop()
400
+ text = event.value.strip()
401
+ if not text or self.busy:
402
+ return
403
+ menu = self.query_one("#command-menu", CommandMenu)
404
+ if (
405
+ menu.display
406
+ and menu.highlighted is not None
407
+ and self.command_choices
408
+ and matching_commands(event.value) == self.command_choices
409
+ ):
410
+ text = self.command_choices[menu.highlighted].name
411
+ event.input.value = ""
412
+ self.action_dismiss_commands()
413
+ await self.dispatch_text(text)
414
+
415
+ async def dispatch_text(self, text: str) -> None:
416
+ if text.startswith("/"):
417
+ parts = text.split()
418
+ command = parts[0].casefold()
419
+ if command == "/file" and len(parts) == 1:
420
+ self.action_file()
421
+ elif command in {"/quit", "/exit"} and len(parts) == 1:
422
+ self.exit()
423
+ elif command in {"/new", "/clear", "/reset"} and len(parts) == 1:
424
+ self.set_busy(True, "starting a new session…")
425
+ self.start_session(keep_document=command != "/reset")
426
+ elif command == "/sessions" and len(parts) <= 2:
427
+ if isinstance(self.backend, SessionControls):
428
+ self.set_busy(True, "loading saved sessions…")
429
+ if len(parts) == 2:
430
+ self.resume_session(parts[1])
431
+ else:
432
+ self.show_sessions()
433
+ else:
434
+ await self.append_message(
435
+ "convo", "Saved sessions are unavailable in shell preview."
436
+ )
437
+ elif command == "/login" and len(parts) == 1:
438
+ if isinstance(self.backend, AccountControls):
439
+ self.set_busy(True, "waiting for ChatGPT sign-in…")
440
+ self.login_openai()
441
+ else:
442
+ await self.append_message(
443
+ "convo",
444
+ "Account controls are unavailable in shell preview. Launch Convo without --shell.",
445
+ )
446
+ elif command == "/models" and len(parts) <= 2:
447
+ if isinstance(self.backend, GenerationControls):
448
+ self.set_busy(True, "loading account models…")
449
+ if len(parts) == 2:
450
+ self.change_model(parts[1])
451
+ else:
452
+ self.load_models()
453
+ else:
454
+ await self.append_message(
455
+ "convo",
456
+ "Model controls are unavailable in shell preview. Launch Convo without --shell.",
457
+ )
458
+ elif command == "/thinking" and len(parts) <= 2:
459
+ if len(parts) == 1:
460
+ self.action_choose_thinking()
461
+ elif isinstance(self.backend, GenerationControls):
462
+ value = parts[1].casefold()
463
+ if value == "auto" or value in REASONING_EFFORTS:
464
+ self.set_busy(True, "updating thinking…")
465
+ self.change_thinking(value)
466
+ else:
467
+ await self.append_message(
468
+ "convo",
469
+ "Unknown thinking level. Use /thinking to see this model's choices.",
470
+ )
471
+ else:
472
+ self.notify("Thinking controls are unavailable in shell preview.")
473
+ elif command == "/help" and len(parts) == 1:
474
+ await self.append_message(
475
+ "commands",
476
+ "\n".join(f"{entry.name:<11}{entry.description}" for entry in COMMANDS)
477
+ + "\n\nCommands: type /, ↑/↓ to move, Tab to complete, Enter to run, Esc to dismiss."
478
+ + f"\nModels: /models to choose; {self.thinking_shortcut} or Shift+Tab cycles thinking."
479
+ + "\nF2 or /thinking chooses a level; /thinking high sets one directly."
480
+ + "\nPageUp/PageDown scroll the conversation while you type."
481
+ + "\nCtrl+O chooses a file; Ctrl+C exits.",
482
+ )
483
+ else:
484
+ await self.append_message(
485
+ "convo", "Unknown command. Type / to see available commands."
486
+ )
487
+ elif self.document is None and not isinstance(self.backend, SessionControls):
488
+ await self.append_message("convo", "Choose a file with /file before asking a question.")
489
+ else:
490
+ await self.append_message("you", text)
491
+ self.set_busy(True, "thinking…")
492
+ self.ask(text)
493
+
494
+ @work(group="backend", exclusive=True)
495
+ async def ask(self, question: str) -> None:
496
+ try:
497
+ if isinstance(self.backend, SessionControls):
498
+ if not self.session.persisted:
499
+ snapshot = await self.backend.new_session(self.document)
500
+ self.session.restore(snapshot)
501
+ answer = await self.backend.answer_session(
502
+ self.session.id, question, self.update_status
503
+ )
504
+ else:
505
+ assert self.document is not None
506
+ answer = await self.backend.answer(self.document, question, self.history)
507
+ await self.append_message("chatgpt", answer.text)
508
+ if answer.sources:
509
+ await self.append_message(
510
+ "sources",
511
+ "\n\n".join(
512
+ f"[{index}] {source.label}\n{source.excerpt}"
513
+ for index, source in enumerate(answer.sources, 1)
514
+ ),
515
+ )
516
+ self.session.record_turn(question, answer.text, answer)
517
+ self.reveal_latest_turn()
518
+ if isinstance(self.backend, GenerationControls):
519
+ try:
520
+ self.selection = self.backend.generation_selection()
521
+ except Exception:
522
+ self.notify("Answer received. Refresh model settings with /models.")
523
+ except BackendNotConnected as error:
524
+ await self.append_message("convo", str(error))
525
+ self.query_one("#prompt", Input).value = question
526
+ except Exception:
527
+ await self.append_message(
528
+ "error",
529
+ "Could not get an answer. Check your connection or reconnect with /login, then retry.",
530
+ )
531
+ self.query_one("#prompt", Input).value = question
532
+ finally:
533
+ self.set_busy(False)
534
+
535
+ @work(group="backend", exclusive=True)
536
+ async def start_session(self, *, keep_document: bool = True) -> None:
537
+ try:
538
+ document = self.document if keep_document else None
539
+ if isinstance(self.backend, SessionControls):
540
+ snapshot = await self.backend.new_session(document)
541
+ self.session.restore(snapshot)
542
+ else:
543
+ self.session.clear(keep_document=keep_document)
544
+ self.query_one("#prompt", Input).value = ""
545
+ await self.clear_conversation()
546
+ await self.append_message(
547
+ "convo",
548
+ f"New conversation · {document.path.name}"
549
+ if document
550
+ else "New conversation. Choose a file with /file to begin.",
551
+ )
552
+ except BackendNotConnected as error:
553
+ await self.append_message("convo", str(error))
554
+ except Exception:
555
+ await self.append_message(
556
+ "error", "Could not create a session. Check PostgreSQL and retry /new."
557
+ )
558
+ finally:
559
+ self.set_busy(False)
560
+
561
+ @work(group="backend", exclusive=True)
562
+ async def show_sessions(self) -> None:
563
+ assert isinstance(self.backend, SessionControls)
564
+ sessions = None
565
+ try:
566
+ sessions = await self.backend.list_sessions()
567
+ except BackendNotConnected as error:
568
+ await self.append_message("convo", str(error))
569
+ except Exception:
570
+ await self.append_message(
571
+ "error", "Could not load saved conversations. Retry /sessions."
572
+ )
573
+ finally:
574
+ self.set_busy(False)
575
+ if sessions is not None:
576
+ self.push_screen(SessionPicker(sessions, self.session.id), self.session_chosen)
577
+
578
+ def session_chosen(self, session_id: str | None) -> None:
579
+ if session_id is not None:
580
+ self.set_busy(True, "restoring conversation…")
581
+ self.resume_session(session_id)
582
+
583
+ @work(group="backend", exclusive=True)
584
+ async def resume_session(self, session_id: str) -> None:
585
+ assert isinstance(self.backend, SessionControls)
586
+ try:
587
+ snapshot = await self.backend.load_session(session_id)
588
+ self.session.restore(snapshot)
589
+ self.query_one("#prompt", Input).value = ""
590
+ await self.clear_conversation()
591
+ await self.append_message("convo", f"Resumed: {snapshot.title}")
592
+ for message in snapshot.history:
593
+ await self.append_message(
594
+ "you" if message.role == "user" else "chatgpt", message.content
595
+ )
596
+ if message.sources:
597
+ await self.append_message(
598
+ "sources",
599
+ "\n\n".join(
600
+ f"[{index}] {source.label}\n{source.excerpt}"
601
+ for index, source in enumerate(message.sources, 1)
602
+ ),
603
+ )
604
+ self.reveal_latest_turn()
605
+ except BackendNotConnected as error:
606
+ await self.append_message("convo", str(error))
607
+ except Exception:
608
+ await self.append_message(
609
+ "error", "Could not restore this conversation. Retry /sessions."
610
+ )
611
+ finally:
612
+ self.set_busy(False)
613
+
614
+ @work(group="backend", exclusive=True)
615
+ async def login_openai(self) -> None:
616
+ assert isinstance(self.backend, AccountControls)
617
+ try:
618
+ account = await self.backend.login_openai()
619
+ await self.append_message(
620
+ "convo",
621
+ f"Connected ChatGPT account: {account}\nUse /models to choose a model; /thinking chooses its thinking effort.",
622
+ )
623
+ if isinstance(self.backend, GenerationControls):
624
+ try:
625
+ self.selection = self.backend.generation_selection()
626
+ await self.backend.list_models()
627
+ self.selection = self.backend.generation_selection()
628
+ except Exception:
629
+ await self.append_message(
630
+ "convo",
631
+ "Account connected, but models could not be loaded. Use /models to retry.",
632
+ )
633
+ except BackendNotConnected as error:
634
+ await self.append_message("convo", str(error))
635
+ except Exception:
636
+ await self.append_message(
637
+ "error", "ChatGPT sign-in failed. Check your connection and retry /login."
638
+ )
639
+ finally:
640
+ self.set_busy(False)
641
+
642
+ @work(group="backend", exclusive=True)
643
+ async def load_models(self, *, show_picker: bool = True) -> None:
644
+ assert isinstance(self.backend, GenerationControls)
645
+ models = ()
646
+ try:
647
+ models = await self.backend.list_models()
648
+ self.selection = self.backend.generation_selection()
649
+ if not models:
650
+ await self.append_message(
651
+ "convo", "No models are available to this account. Reconnect with /login."
652
+ )
653
+ except BackendNotConnected as error:
654
+ models = ()
655
+ await self.append_message("convo", str(error))
656
+ except Exception:
657
+ models = ()
658
+ await self.append_message(
659
+ "error", "Could not load ChatGPT models. Check your connection and retry /models."
660
+ )
661
+ finally:
662
+ self.set_busy(False)
663
+ if show_picker and models:
664
+ selected = self.selection.model.id if self.selection.model else None
665
+ self.push_screen(ModelPicker(models, selected), self.model_chosen)
666
+
667
+ def model_chosen(self, model_id: str | None) -> None:
668
+ if model_id is not None:
669
+ self.set_busy(True, "changing model…")
670
+ self.change_model(model_id)
671
+
672
+ @work(group="backend", exclusive=True)
673
+ async def change_model(self, model_id: str) -> None:
674
+ assert isinstance(self.backend, GenerationControls)
675
+ try:
676
+ self.selection = await self.backend.select_model(model_id)
677
+ except (BackendNotConnected, ValueError) as error:
678
+ await self.append_message("convo", str(error))
679
+ except Exception:
680
+ await self.append_message("error", "Could not change the model. Retry /models.")
681
+ finally:
682
+ self.set_busy(False)
683
+
684
+ def action_cycle_thinking(self) -> None:
685
+ if not isinstance(self.backend, GenerationControls):
686
+ self.notify("Thinking controls are unavailable in shell preview.")
687
+ return
688
+ self.set_busy(True, "updating thinking…")
689
+ self.cycle_thinking()
690
+
691
+ def action_choose_thinking(self) -> None:
692
+ if not isinstance(self.backend, GenerationControls):
693
+ self.notify("Thinking controls are unavailable in shell preview.")
694
+ return
695
+ self.action_dismiss_commands()
696
+ self.set_busy(True, "loading thinking levels…")
697
+ self.load_thinking()
698
+
699
+ @work(group="backend", exclusive=True)
700
+ async def load_thinking(self) -> None:
701
+ assert isinstance(self.backend, GenerationControls)
702
+ model = None
703
+ try:
704
+ await self.backend.list_models()
705
+ self.selection = self.backend.generation_selection()
706
+ model = self.selection.model
707
+ except BackendNotConnected as error:
708
+ await self.append_message("convo", str(error))
709
+ except Exception:
710
+ await self.append_message("error", "Could not load thinking levels. Retry /thinking.")
711
+ finally:
712
+ self.set_busy(False)
713
+ if model:
714
+ self.push_screen(
715
+ ThinkingPicker(model, self.selection.reasoning_effort), self.thinking_chosen
716
+ )
717
+
718
+ def thinking_chosen(self, value: str | None) -> None:
719
+ if value is not None:
720
+ self.set_busy(True, "updating thinking…")
721
+ self.change_thinking(value)
722
+
723
+ @work(group="backend", exclusive=True)
724
+ async def change_thinking(self, value: str) -> None:
725
+ assert isinstance(self.backend, GenerationControls)
726
+ effort: ReasoningEffort | None = next(
727
+ (item for item in REASONING_EFFORTS if item == value), None
728
+ )
729
+ try:
730
+ self.selection = await self.backend.select_reasoning(effort)
731
+ except (BackendNotConnected, ValueError) as error:
732
+ await self.append_message("convo", str(error))
733
+ except Exception:
734
+ await self.append_message("error", "Could not change thinking. Retry /thinking.")
735
+ finally:
736
+ self.set_busy(False)
737
+
738
+ @work(group="backend", exclusive=True)
739
+ async def cycle_thinking(self) -> None:
740
+ assert isinstance(self.backend, GenerationControls)
741
+ try:
742
+ self.selection = await self.backend.cycle_reasoning()
743
+ if self.selection.model and not self.selection.model.reasoning_efforts:
744
+ self.notify("This model uses automatic thinking; no adjustable levels are known.")
745
+ except BackendNotConnected as error:
746
+ await self.append_message("convo", str(error))
747
+ except Exception:
748
+ await self.append_message(
749
+ "error", "Could not change thinking. Refresh the account models with /models."
750
+ )
751
+ finally:
752
+ self.set_busy(False)