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.
- convo/__init__.py +3 -0
- convo/__main__.py +110 -0
- convo/bootstrap.py +273 -0
- convo/core/__init__.py +1 -0
- convo/core/config.py +53 -0
- convo/core/contracts.py +160 -0
- convo/core/exceptions.py +5 -0
- convo/db/__init__.py +1 -0
- convo/db/base.py +13 -0
- convo/db/chunks.py +132 -0
- convo/db/conversations.py +167 -0
- convo/db/documents.py +34 -0
- convo/db/models.py +81 -0
- convo/db/session.py +27 -0
- convo/llm/__init__.py +0 -0
- convo/llm/auth.py +504 -0
- convo/llm/client.py +207 -0
- convo/llm/credentials.py +192 -0
- convo/llm/models.py +71 -0
- convo/llm/streaming.py +62 -0
- convo/migrations/env.py +43 -0
- convo/migrations/script.py.mako +22 -0
- convo/migrations/versions/5c3b75f9c5c1_describe_your_schema_change.py +22 -0
- convo/migrations/versions/732cf62e465c_create_rag_tables.py +49 -0
- convo/migrations/versions/README.md +3 -0
- convo/migrations/versions/a42e9d8f0b31_hybrid_search_and_chat_sessions.py +66 -0
- convo/pipeline/__init__.py +0 -0
- convo/pipeline/chat.py +129 -0
- convo/pipeline/chunking.py +42 -0
- convo/pipeline/embeddings.py +83 -0
- convo/pipeline/index.py +160 -0
- convo/pipeline/ingestion.py +46 -0
- convo/pipeline/reranking.py +63 -0
- convo/pipeline/routing.py +64 -0
- convo/pipeline/sessions.py +40 -0
- convo/pipeline/worker.py +226 -0
- convo/prompts/__init__.py +0 -0
- convo/prompts/answers.py +97 -0
- convo/prompts/schema.py +97 -0
- convo/ui/__init__.py +1 -0
- convo/ui/app.py +752 -0
- convo/ui/commands.py +37 -0
- convo/ui/files.py +110 -0
- convo/ui/models.py +84 -0
- convo/ui/preview.py +20 -0
- convo/ui/sessions.py +97 -0
- convo/ui/theme.tcss +64 -0
- convo/ui/thinking.py +70 -0
- convo_cli-0.1.0.dist-info/METADATA +160 -0
- convo_cli-0.1.0.dist-info/RECORD +53 -0
- convo_cli-0.1.0.dist-info/WHEEL +4 -0
- convo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|