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/commands.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""One command registry shared by suggestions, completion, and /help."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from textual.widgets import OptionList
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Command:
|
|
10
|
+
name: str
|
|
11
|
+
description: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
COMMANDS = (
|
|
15
|
+
Command("/file", "Choose a file to chat with"),
|
|
16
|
+
Command("/login", "Connect your ChatGPT account"),
|
|
17
|
+
Command("/models", "Choose an account model"),
|
|
18
|
+
Command("/thinking", "Choose the model's thinking effort"),
|
|
19
|
+
Command("/sessions", "Resume a saved conversation"),
|
|
20
|
+
Command("/new", "Start a new chat with this file"),
|
|
21
|
+
Command("/clear", "Start fresh, keep your file"),
|
|
22
|
+
Command("/reset", "Start fresh without a file"),
|
|
23
|
+
Command("/help", "Show commands and keyboard shortcuts"),
|
|
24
|
+
Command("/quit", "Exit Convo"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def matching_commands(text: str) -> tuple[Command, ...]:
|
|
29
|
+
if not text.startswith("/") or any(character.isspace() for character in text):
|
|
30
|
+
return ()
|
|
31
|
+
return tuple(command for command in COMMANDS if command.name.startswith(text.casefold()))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CommandMenu(OptionList):
|
|
35
|
+
"""Let the composer keep focus while arrows and Tab operate suggestions."""
|
|
36
|
+
|
|
37
|
+
can_focus = False
|
convo/ui/files.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""A searchable picker rooted at the directory where Convo was launched."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.events import Key
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Input, OptionList, Static
|
|
12
|
+
from textual.widgets.option_list import Option
|
|
13
|
+
|
|
14
|
+
SUPPORTED = {".txt", ".md", ".pdf", ".docx", ".csv", ".json", ".py", ".rst", ".log"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FilePicker(ModalScreen[Path | None]):
|
|
18
|
+
BINDINGS: ClassVar = [("escape", "cancel", "Cancel")]
|
|
19
|
+
|
|
20
|
+
def __init__(self, root: Path) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.root = root.resolve()
|
|
23
|
+
self.folder = self.root
|
|
24
|
+
self.entries: list[Path] = []
|
|
25
|
+
|
|
26
|
+
def compose(self) -> ComposeResult:
|
|
27
|
+
with Vertical(id="picker"):
|
|
28
|
+
yield Static("Choose a file", id="picker-title")
|
|
29
|
+
yield Static("", id="picker-path", markup=False)
|
|
30
|
+
yield Input(placeholder="Filter files…", id="file-search")
|
|
31
|
+
yield OptionList(id="file-options")
|
|
32
|
+
yield Static("↑ ↓ move enter select tab list esc close", id="picker-help")
|
|
33
|
+
|
|
34
|
+
def on_mount(self) -> None:
|
|
35
|
+
self.refresh_entries()
|
|
36
|
+
self.query_one("#file-search", Input).focus()
|
|
37
|
+
|
|
38
|
+
def refresh_entries(self, search: str = "") -> None:
|
|
39
|
+
options = self.query_one("#file-options", OptionList)
|
|
40
|
+
options.clear_options()
|
|
41
|
+
self.entries = []
|
|
42
|
+
self.query_one("#picker-path", Static).update(str(self.folder))
|
|
43
|
+
try:
|
|
44
|
+
candidates = []
|
|
45
|
+
for path in self.folder.iterdir():
|
|
46
|
+
if path.name.startswith(".") or path.is_symlink():
|
|
47
|
+
continue
|
|
48
|
+
if (
|
|
49
|
+
path.is_dir() or path.suffix.lower() in SUPPORTED
|
|
50
|
+
) and search.casefold() in path.name.casefold():
|
|
51
|
+
candidates.append(path)
|
|
52
|
+
self.entries = sorted(candidates, key=lambda p: (not p.is_dir(), p.name.casefold()))
|
|
53
|
+
if self.folder != self.root:
|
|
54
|
+
self.entries.insert(0, self.folder.parent)
|
|
55
|
+
for index, path in enumerate(self.entries):
|
|
56
|
+
name = (
|
|
57
|
+
"../"
|
|
58
|
+
if path == self.folder.parent
|
|
59
|
+
else path.name + ("/" if path.is_dir() else "")
|
|
60
|
+
)
|
|
61
|
+
options.add_option(Option(Text(name), id=str(index)))
|
|
62
|
+
if not self.entries:
|
|
63
|
+
options.add_option(Option("No supported files found", disabled=True))
|
|
64
|
+
else:
|
|
65
|
+
options.highlighted = 0
|
|
66
|
+
except OSError:
|
|
67
|
+
options.add_option(Option("Cannot read this directory", disabled=True))
|
|
68
|
+
|
|
69
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
70
|
+
event.stop()
|
|
71
|
+
self.refresh_entries(event.value)
|
|
72
|
+
|
|
73
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
74
|
+
event.stop()
|
|
75
|
+
options = self.query_one("#file-options", OptionList)
|
|
76
|
+
if options.highlighted is not None and self.entries:
|
|
77
|
+
self.choose(options.highlighted)
|
|
78
|
+
|
|
79
|
+
def on_key(self, event: Key) -> None:
|
|
80
|
+
if self.query_one("#file-search", Input).has_focus and event.key in {"down", "up"}:
|
|
81
|
+
options = self.query_one("#file-options", OptionList)
|
|
82
|
+
if event.key == "down":
|
|
83
|
+
options.action_cursor_down()
|
|
84
|
+
else:
|
|
85
|
+
options.action_cursor_up()
|
|
86
|
+
event.stop()
|
|
87
|
+
event.prevent_default()
|
|
88
|
+
|
|
89
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
90
|
+
event.stop()
|
|
91
|
+
self.choose(event.option_index)
|
|
92
|
+
|
|
93
|
+
def choose(self, index: int) -> None:
|
|
94
|
+
if not 0 <= index < len(self.entries):
|
|
95
|
+
return
|
|
96
|
+
path = self.entries[index].resolve()
|
|
97
|
+
if not path.is_relative_to(self.root):
|
|
98
|
+
return
|
|
99
|
+
if path.is_dir():
|
|
100
|
+
self.folder = path
|
|
101
|
+
self.query_one("#file-search", Input).value = ""
|
|
102
|
+
self.refresh_entries()
|
|
103
|
+
self.query_one("#file-search", Input).focus()
|
|
104
|
+
elif path.is_file():
|
|
105
|
+
self.dismiss(path)
|
|
106
|
+
else:
|
|
107
|
+
self.refresh_entries()
|
|
108
|
+
|
|
109
|
+
def action_cancel(self) -> None:
|
|
110
|
+
self.dismiss(None)
|
convo/ui/models.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""A searchable picker populated only from the signed-in account's catalog."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.events import Key
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Input, OptionList, Static
|
|
11
|
+
from textual.widgets.option_list import Option
|
|
12
|
+
|
|
13
|
+
from convo.core.contracts import ChatModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelPicker(ModalScreen[str | None]):
|
|
17
|
+
BINDINGS: ClassVar = [("escape", "cancel", "Cancel")]
|
|
18
|
+
|
|
19
|
+
def __init__(self, models: tuple[ChatModel, ...], selected: str | None) -> None:
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.models = models
|
|
22
|
+
self.selected = selected
|
|
23
|
+
self.entries: tuple[ChatModel, ...] = ()
|
|
24
|
+
|
|
25
|
+
def compose(self) -> ComposeResult:
|
|
26
|
+
with Vertical(id="model-picker"):
|
|
27
|
+
yield Static("Choose a ChatGPT model", id="model-title")
|
|
28
|
+
yield Static("Available to your connected account", id="model-subtitle")
|
|
29
|
+
yield Input(placeholder="Filter by model name…", id="model-search")
|
|
30
|
+
yield OptionList(id="model-options")
|
|
31
|
+
yield Static("↑ ↓ move enter select esc cancel", id="model-help")
|
|
32
|
+
|
|
33
|
+
def on_mount(self) -> None:
|
|
34
|
+
self.refresh_entries()
|
|
35
|
+
self.query_one("#model-search", Input).focus()
|
|
36
|
+
|
|
37
|
+
def refresh_entries(self, search: str = "") -> None:
|
|
38
|
+
options = self.query_one("#model-options", OptionList)
|
|
39
|
+
options.clear_options()
|
|
40
|
+
self.entries = tuple(
|
|
41
|
+
model
|
|
42
|
+
for model in self.models
|
|
43
|
+
if search.casefold() in f"{model.name} {model.id}".casefold()
|
|
44
|
+
)
|
|
45
|
+
for model in self.entries:
|
|
46
|
+
label = Text()
|
|
47
|
+
label.append("● " if model.id == self.selected else " ", style="#aaaaaa")
|
|
48
|
+
label.append(model.name, style="bold")
|
|
49
|
+
label.append(f"\n {model.id}", style="#888888")
|
|
50
|
+
options.add_option(Option(label, id=model.id))
|
|
51
|
+
if not self.entries:
|
|
52
|
+
options.add_option(Option("No matching models", disabled=True))
|
|
53
|
+
else:
|
|
54
|
+
options.highlighted = next(
|
|
55
|
+
(index for index, model in enumerate(self.entries) if model.id == self.selected), 0
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
59
|
+
event.stop()
|
|
60
|
+
self.refresh_entries(event.value)
|
|
61
|
+
|
|
62
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
63
|
+
event.stop()
|
|
64
|
+
highlighted = self.query_one("#model-options", OptionList).highlighted
|
|
65
|
+
if highlighted is not None and self.entries:
|
|
66
|
+
self.dismiss(self.entries[highlighted].id)
|
|
67
|
+
|
|
68
|
+
def on_key(self, event: Key) -> None:
|
|
69
|
+
if self.query_one("#model-search", Input).has_focus and event.key in {"up", "down"}:
|
|
70
|
+
options = self.query_one("#model-options", OptionList)
|
|
71
|
+
if event.key == "down":
|
|
72
|
+
options.action_cursor_down()
|
|
73
|
+
else:
|
|
74
|
+
options.action_cursor_up()
|
|
75
|
+
event.stop()
|
|
76
|
+
event.prevent_default()
|
|
77
|
+
|
|
78
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
79
|
+
event.stop()
|
|
80
|
+
if 0 <= event.option_index < len(self.entries):
|
|
81
|
+
self.dismiss(self.entries[event.option_index].id)
|
|
82
|
+
|
|
83
|
+
def action_cancel(self) -> None:
|
|
84
|
+
self.dismiss(None)
|
convo/ui/preview.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""UI-only adapter. This does not read, embed, upload, or answer from a file."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from convo.core.contracts import Answer, Document, Message
|
|
6
|
+
from convo.core.exceptions import BackendNotConnected
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ShellBackend:
|
|
10
|
+
async def prepare(self, path: Path) -> Document:
|
|
11
|
+
if not path.is_file():
|
|
12
|
+
raise ValueError("That file is no longer available. Use /file to choose another.")
|
|
13
|
+
return Document(id=str(path), path=path, indexed=False)
|
|
14
|
+
|
|
15
|
+
async def answer(
|
|
16
|
+
self, document: Document, question: str, history: tuple[Message, ...]
|
|
17
|
+
) -> Answer:
|
|
18
|
+
raise BackendNotConnected(
|
|
19
|
+
"Shell preview only. Run `convo` to use local storage, embeddings, and ChatGPT."
|
|
20
|
+
)
|
convo/ui/sessions.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Search and resume the conversations stored in PostgreSQL."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.events import Key
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Input, OptionList, Static
|
|
11
|
+
from textual.widgets.option_list import Option
|
|
12
|
+
|
|
13
|
+
from convo.core.contracts import SessionSummary
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SessionPicker(ModalScreen[str | None]):
|
|
17
|
+
BINDINGS: ClassVar = [("escape", "cancel", "Cancel")]
|
|
18
|
+
|
|
19
|
+
def __init__(self, sessions: tuple[SessionSummary, ...], selected: str | None) -> None:
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.sessions, self.selected = sessions, selected
|
|
22
|
+
self.entries: tuple[SessionSummary, ...] = ()
|
|
23
|
+
|
|
24
|
+
def compose(self) -> ComposeResult:
|
|
25
|
+
with Vertical(id="session-picker"):
|
|
26
|
+
yield Static("Your conversations", id="session-title")
|
|
27
|
+
yield Static("Resume a saved file and its history", id="session-subtitle")
|
|
28
|
+
yield Input(placeholder="Filter by title, file, or session ID…", id="session-search")
|
|
29
|
+
yield OptionList(id="session-options")
|
|
30
|
+
yield Static("↑ ↓ move enter resume esc cancel", id="session-help")
|
|
31
|
+
|
|
32
|
+
def on_mount(self) -> None:
|
|
33
|
+
self.refresh_entries()
|
|
34
|
+
self.query_one("#session-search", Input).focus()
|
|
35
|
+
|
|
36
|
+
def refresh_entries(self, search: str = "") -> None:
|
|
37
|
+
options = self.query_one("#session-options", OptionList)
|
|
38
|
+
options.clear_options()
|
|
39
|
+
self.entries = tuple(
|
|
40
|
+
session
|
|
41
|
+
for session in self.sessions
|
|
42
|
+
if search.casefold()
|
|
43
|
+
in f"{session.title} {session.filename or ''} {session.id}".casefold()
|
|
44
|
+
)
|
|
45
|
+
for session in self.entries:
|
|
46
|
+
label = Text()
|
|
47
|
+
label.append("● " if session.id == self.selected else " ", style="#aaaaaa")
|
|
48
|
+
label.append(session.title.replace("\n", " ")[:100], style="bold")
|
|
49
|
+
date = session.updated_at.astimezone().strftime("%d %b %H:%M")
|
|
50
|
+
turns = session.message_count // 2
|
|
51
|
+
turn_label = "turn" if turns == 1 else "turns"
|
|
52
|
+
label.append(
|
|
53
|
+
f"\n {session.filename or 'no file'} · {turns} {turn_label} · {date}",
|
|
54
|
+
style="#888888",
|
|
55
|
+
)
|
|
56
|
+
options.add_option(Option(label, id=session.id))
|
|
57
|
+
if not self.entries:
|
|
58
|
+
options.add_option(
|
|
59
|
+
Option(
|
|
60
|
+
"No matching sessions"
|
|
61
|
+
if self.sessions
|
|
62
|
+
else "No saved sessions yet. Start with /new or /file.",
|
|
63
|
+
disabled=True,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
options.highlighted = next(
|
|
68
|
+
(index for index, item in enumerate(self.entries) if item.id == self.selected), 0
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
72
|
+
event.stop()
|
|
73
|
+
self.refresh_entries(event.value)
|
|
74
|
+
|
|
75
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
76
|
+
event.stop()
|
|
77
|
+
highlighted = self.query_one("#session-options", OptionList).highlighted
|
|
78
|
+
if highlighted is not None and self.entries:
|
|
79
|
+
self.dismiss(self.entries[highlighted].id)
|
|
80
|
+
|
|
81
|
+
def on_key(self, event: Key) -> None:
|
|
82
|
+
if self.query_one("#session-search", Input).has_focus and event.key in {"up", "down"}:
|
|
83
|
+
options = self.query_one("#session-options", OptionList)
|
|
84
|
+
if event.key == "down":
|
|
85
|
+
options.action_cursor_down()
|
|
86
|
+
else:
|
|
87
|
+
options.action_cursor_up()
|
|
88
|
+
event.stop()
|
|
89
|
+
event.prevent_default()
|
|
90
|
+
|
|
91
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
92
|
+
event.stop()
|
|
93
|
+
if 0 <= event.option_index < len(self.entries):
|
|
94
|
+
self.dismiss(self.entries[event.option_index].id)
|
|
95
|
+
|
|
96
|
+
def action_cancel(self) -> None:
|
|
97
|
+
self.dismiss(None)
|
convo/ui/theme.tcss
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
background: #080808;
|
|
3
|
+
color: #dddddd;
|
|
4
|
+
padding: 2 3 1 3;
|
|
5
|
+
}
|
|
6
|
+
#masthead { height: 4; margin-bottom: 1; }
|
|
7
|
+
#brand { width: 29; height: 4; }
|
|
8
|
+
#identity { width: 1fr; height: 4; margin-left: 3; padding-top: 1; }
|
|
9
|
+
#version { height: 1; color: #a7a095; }
|
|
10
|
+
#tagline { height: 1; color: #777777; }
|
|
11
|
+
#directory { height: 1; color: #555555; }
|
|
12
|
+
#conversation { height: 1fr; min-height: 1; margin: 1 0; scrollbar-size: 1 1; scrollbar-color: #494134; scrollbar-background: #080808; }
|
|
13
|
+
#welcome { color: #888888; height: auto; }
|
|
14
|
+
.message { height: auto; margin-bottom: 2; }
|
|
15
|
+
.user-message { background: #151515; border-left: solid #e6b85c; padding: 1 2; color: #eeeeee; }
|
|
16
|
+
.assistant-message { padding: 0 3; }
|
|
17
|
+
.notice-message { padding: 0 3; color: #bbbbbb; margin-bottom: 1; }
|
|
18
|
+
.source-message { background: #0e0e0e; border-left: solid #494134; padding: 1 2; color: #aaaaaa; }
|
|
19
|
+
.error-message { border-left: solid #b97863; padding: 0 2; }
|
|
20
|
+
#composer { height: auto; }
|
|
21
|
+
#input-panel { height: auto; background: #191919; border-left: solid #e6b85c; padding: 1 2; }
|
|
22
|
+
#settings { height: 1; margin-top: 1; }
|
|
23
|
+
#selection { height: 1; width: 1fr; min-width: 0; color: #e6b85c; }
|
|
24
|
+
#thinking { height: 1; width: auto; margin-left: 2; color: #e6b85c; }
|
|
25
|
+
#prompt { height: 1; border: none; background: #191919; padding: 0; }
|
|
26
|
+
#prompt:focus { border: none; background: #191919; }
|
|
27
|
+
#prompt:disabled { opacity: 60%; }
|
|
28
|
+
Input { color: #eeeeee; }
|
|
29
|
+
Input > .input--placeholder { color: #666666; }
|
|
30
|
+
#status { height: 1; color: #888888; margin-top: 1; }
|
|
31
|
+
#keys { height: 1; color: #666055; margin-top: 1; }
|
|
32
|
+
#command-menu { display: none; border: solid #494134; background: #0c0c0c; padding: 0; margin-bottom: 1; scrollbar-size: 1 1; scrollbar-color: #6d582f; scrollbar-background: #0c0c0c; }
|
|
33
|
+
#command-menu > .option-list--option-highlighted { background: #292318; color: #f2cc70; }
|
|
34
|
+
FilePicker, ModelPicker, SessionPicker, ThinkingPicker { align: center middle; background: #000000 75%; padding: 1; }
|
|
35
|
+
#picker, #model-picker, #session-picker, #thinking-picker { width: 80; max-width: 100%; height: 24; max-height: 100%; background: #0c0c0c; border: solid #a18245; padding: 1 2; }
|
|
36
|
+
#thinking-picker { width: 52; height: auto; max-height: 100%; }
|
|
37
|
+
#picker-title, #model-title, #session-title, #thinking-title { height: 1; color: #e6b85c; text-style: bold; margin-bottom: 1; }
|
|
38
|
+
#picker-path, #model-subtitle, #session-subtitle, #thinking-subtitle { height: 1; color: #888888; margin-bottom: 1; }
|
|
39
|
+
#file-search, #model-search, #session-search { height: 3; border: solid #444444; background: #0c0c0c; }
|
|
40
|
+
#file-search:focus, #model-search:focus, #session-search:focus { border: solid #e6b85c; }
|
|
41
|
+
#file-options, #model-options, #session-options, #thinking-options { height: 1fr; border: none; background: #0c0c0c; padding: 0; margin-top: 1; scrollbar-size: 1 1; scrollbar-color: #6d582f; scrollbar-background: #0c0c0c; }
|
|
42
|
+
#thinking-options { height: auto; max-height: 8; margin-top: 0; }
|
|
43
|
+
#file-options > .option-list--option-highlighted, #model-options > .option-list--option-highlighted, #session-options > .option-list--option-highlighted, #thinking-options > .option-list--option-highlighted { background: #292318; color: #f2cc70; text-style: none; }
|
|
44
|
+
#picker-help, #model-help, #session-help, #thinking-help { height: auto; color: #777777; margin-top: 1; }
|
|
45
|
+
Screen.compact { padding: 1; }
|
|
46
|
+
Screen.compact #masthead { height: 1; margin-bottom: 0; }
|
|
47
|
+
Screen.compact #brand { width: 1fr; height: 1; }
|
|
48
|
+
Screen.compact #identity { display: none; }
|
|
49
|
+
Screen.compact #conversation { margin: 1 0 0 0; }
|
|
50
|
+
Screen.compact #input-panel { padding: 0 1; }
|
|
51
|
+
Screen.compact #settings { margin-top: 0; }
|
|
52
|
+
Screen.compact #status { margin-top: 0; }
|
|
53
|
+
Screen.compact #keys { margin-top: 0; }
|
|
54
|
+
Screen.compact #command-menu { margin-bottom: 0; }
|
|
55
|
+
ThinkingPicker.compact #thinking-picker { padding: 0 2; }
|
|
56
|
+
ThinkingPicker.compact #thinking-title, ThinkingPicker.compact #thinking-subtitle { margin-bottom: 0; }
|
|
57
|
+
Screen.chatting { padding: 1 2; }
|
|
58
|
+
Screen.chatting #masthead { height: 1; margin-bottom: 0; }
|
|
59
|
+
Screen.chatting #brand { width: 1fr; height: 1; }
|
|
60
|
+
Screen.chatting #identity { display: none; }
|
|
61
|
+
Screen.chatting.compact { padding: 1; }
|
|
62
|
+
Screen.compact .message { margin-bottom: 1; }
|
|
63
|
+
Screen.compact .user-message, Screen.compact .source-message { padding: 0 1; }
|
|
64
|
+
Screen.compact .assistant-message, Screen.compact .notice-message { padding: 0 2; }
|
convo/ui/thinking.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Direct thinking selection and the configurable cycling shortcut."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.events import Resize
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import OptionList, Static
|
|
11
|
+
from textual.widgets.option_list import Option
|
|
12
|
+
|
|
13
|
+
from convo.core.contracts import ChatModel, ReasoningEffort
|
|
14
|
+
|
|
15
|
+
GOLD = "#e6b85c"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def thinking_key(value: str) -> str:
|
|
19
|
+
"""Accept terminal-friendly shortcuts without replacing essential controls."""
|
|
20
|
+
key = value.strip().lower()
|
|
21
|
+
if key == "shift+tab" or re.fullmatch(r"f(?:[3-9]|1[0-9]|2[0-4])", key):
|
|
22
|
+
return key
|
|
23
|
+
if re.fullmatch(r"(?:ctrl|alt)\+[a-z]", key) and key not in {
|
|
24
|
+
"ctrl+c",
|
|
25
|
+
"ctrl+o",
|
|
26
|
+
"ctrl+i",
|
|
27
|
+
"ctrl+j",
|
|
28
|
+
"ctrl+m",
|
|
29
|
+
"ctrl+h",
|
|
30
|
+
"ctrl+z",
|
|
31
|
+
}:
|
|
32
|
+
return key
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"Use ctrl+t, another Ctrl/Alt letter, or F3–F24; reserved keys are unavailable."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ThinkingPicker(ModalScreen[str | None]):
|
|
39
|
+
BINDINGS: ClassVar = [("escape", "cancel", "Cancel")]
|
|
40
|
+
|
|
41
|
+
def __init__(self, model: ChatModel, selected: ReasoningEffort | None) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.model = model
|
|
44
|
+
self.selected = selected or "auto"
|
|
45
|
+
|
|
46
|
+
def compose(self) -> ComposeResult:
|
|
47
|
+
with Vertical(id="thinking-picker"):
|
|
48
|
+
yield Static("Thinking effort", id="thinking-title")
|
|
49
|
+
yield Static(self.model.name, id="thinking-subtitle", markup=False)
|
|
50
|
+
options = [Option("auto · model default", id="auto")]
|
|
51
|
+
options.extend(Option(effort, id=effort) for effort in self.model.reasoning_efforts)
|
|
52
|
+
yield OptionList(*options, id="thinking-options")
|
|
53
|
+
yield Static("↑ ↓ move enter select esc cancel", id="thinking-help")
|
|
54
|
+
|
|
55
|
+
def on_mount(self) -> None:
|
|
56
|
+
self.set_class(self.app.size.height < 24, "compact")
|
|
57
|
+
options = self.query_one("#thinking-options", OptionList)
|
|
58
|
+
choices = ("auto", *self.model.reasoning_efforts)
|
|
59
|
+
options.highlighted = choices.index(self.selected) if self.selected in choices else 0
|
|
60
|
+
options.focus()
|
|
61
|
+
|
|
62
|
+
def on_resize(self, event: Resize) -> None:
|
|
63
|
+
self.set_class(event.size.height < 24, "compact")
|
|
64
|
+
|
|
65
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
66
|
+
event.stop()
|
|
67
|
+
self.dismiss(event.option.id)
|
|
68
|
+
|
|
69
|
+
def action_cancel(self) -> None:
|
|
70
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: convo-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local terminal RAG assistant for chatting with selected files.
|
|
5
|
+
Project-URL: Homepage, https://github.com/madvillax/convo
|
|
6
|
+
Project-URL: Repository, https://github.com/madvillax/convo
|
|
7
|
+
Project-URL: Issues, https://github.com/madvillax/convo/issues
|
|
8
|
+
Author: Mohit Sharma
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,local-first,rag,terminal,textual
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Text Processing :: General
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: alembic>=1.20.0
|
|
23
|
+
Requires-Dist: docx2txt>=0.9
|
|
24
|
+
Requires-Dist: httpx>=0.28.1
|
|
25
|
+
Requires-Dist: keyring>=25.6
|
|
26
|
+
Requires-Dist: llama-index-core>=0.14.25
|
|
27
|
+
Requires-Dist: llama-index-readers-file>=0.7.0
|
|
28
|
+
Requires-Dist: numpy>=2.4.6
|
|
29
|
+
Requires-Dist: openai>=2.0
|
|
30
|
+
Requires-Dist: pgvector>=0.5.1
|
|
31
|
+
Requires-Dist: psycopg[binary]>=3
|
|
32
|
+
Requires-Dist: pydantic<3,>=2.0
|
|
33
|
+
Requires-Dist: pyjwt[crypto]>=2.10
|
|
34
|
+
Requires-Dist: python-dotenv>=1.2.4
|
|
35
|
+
Requires-Dist: sentence-transformers>=6.1.0
|
|
36
|
+
Requires-Dist: sqlalchemy<3,>=2.0
|
|
37
|
+
Requires-Dist: textual<9,>=8.2
|
|
38
|
+
Requires-Dist: transformers>=4.51
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# Convo
|
|
42
|
+
|
|
43
|
+
Convo is an open-source, local-first terminal app for chatting with a selected file. Qwen3 embeddings and PostgreSQL full-text search retrieve passages together; a local cross-encoder reranks them; your connected ChatGPT account returns a structured answer with supporting quotations.
|
|
44
|
+
|
|
45
|
+
It is released under the [MIT License](LICENSE). Contributions are welcome—see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
46
|
+
|
|
47
|
+
## Install and start
|
|
48
|
+
|
|
49
|
+
The supported release install uses [uv](https://docs.astral.sh/uv/):
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
uv tool install convo-cli
|
|
53
|
+
convo
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
On first launch, `convo` creates a private settings file at `~/.config/convo/.env`, pulls `pgvector/pgvector:pg16`, starts a loopback-only `convo-postgres` container, and runs migrations before opening the terminal app. It generates a random database password and preserves existing data. Later launches restart Convo's local container if needed and apply pending migrations.
|
|
57
|
+
|
|
58
|
+
Local storage needs Docker Desktop or Podman installed. Start Docker Desktop first if you use Docker. On macOS, Convo starts or initializes a Podman machine when Podman is installed. First use downloads Python dependencies, a database image, and—when indexing your first file—the embedding/reranker models.
|
|
59
|
+
|
|
60
|
+
If you already use PostgreSQL, set `DATABASE_URL` to a `postgresql+psycopg://...` URL, run `convo --setup` once to migrate that database, then run `convo`. Explicit database connections are used as configured; startup only manages storage created by Convo.
|
|
61
|
+
|
|
62
|
+
For a one-command install on macOS/Linux, inspect and then run the repository installer:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
curl -LsSf https://raw.githubusercontent.com/madvillax/convo/main/scripts/install.sh | sh
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
It installs uv if needed, installs `convo-cli`, and adds uv's tool directory to your shell path. Then type `convo` to start. Open a new terminal if `convo` is not immediately found.
|
|
69
|
+
|
|
70
|
+
To use an unreleased checkout while developing:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
uv sync --python 3.12
|
|
74
|
+
uv run convo
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`--directory ~/Documents` changes the file browser root. Environment variables always override the user settings file; `.env.example` is only for checkout-based development.
|
|
78
|
+
|
|
79
|
+
Optional model download/warmup outside the TUI:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
uv run convo --download-model
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This prepares both Qwen3 and `cross-encoder/ms-marco-MiniLM-L6-v2`. Models otherwise load/download on demand in an isolated process. File preparation shows stages and elapsed time; Esc cancels it. Download/native logs stay in `~/.cache/convo/rag-worker.log` (respecting `XDG_CACHE_HOME`). After storage preparation, startup restores account preferences locally; models and the account catalog load when needed.
|
|
86
|
+
|
|
87
|
+
For a UI preview without services or backend dependencies:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
uv run convo --shell
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Use
|
|
94
|
+
|
|
95
|
+
Type `/` for command suggestions. Filter by typing, use ↑/↓ to move, Tab to complete, Enter to run, and Esc to dismiss.
|
|
96
|
+
|
|
97
|
+
| Command/key | Action |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `/login` | Connect your ChatGPT account through browser sign-in |
|
|
100
|
+
| `/models` | Search and select an account model |
|
|
101
|
+
| `/models <id>` | Select a model directly |
|
|
102
|
+
| Ctrl+T or Shift+Tab | Cycle auto and the model's supported thinking levels |
|
|
103
|
+
| F2 or `/thinking` | Choose a thinking level directly |
|
|
104
|
+
| `/thinking high`, `/thinking auto` | Set a supported level or restore the model default |
|
|
105
|
+
| `/file`, Ctrl+O | Select and index a file; create a saved conversation |
|
|
106
|
+
| `/sessions` | Search saved conversations by title, filename, or ID and resume one |
|
|
107
|
+
| `/sessions <uuid>` | Resume a conversation directly |
|
|
108
|
+
| `/new` | Create a new saved conversation, keeping the selected file |
|
|
109
|
+
| `/clear` | Same behavior as `/new`; previous conversations remain saved |
|
|
110
|
+
| `/reset` | Create a new saved conversation without a selected file |
|
|
111
|
+
| Esc during file preparation | Stop preparation and restore the input |
|
|
112
|
+
| `/help` | Show commands and keys |
|
|
113
|
+
| `/quit`, `/exit`, Ctrl+C | Exit |
|
|
114
|
+
| PageUp / PageDown | Scroll the transcript while keeping the input focused |
|
|
115
|
+
|
|
116
|
+
Questions appear in shaded blocks with a gold left edge. Answers use spaced Markdown with highlighted code, and source quotations appear in a separate subdued block. The composer groups your input with the gold model name and thinking level underneath. The ASCII title collapses once chatting starts and in smaller terminals, leaving more room to read. Long replies open at the question and the beginning of the answer; use PageUp/PageDown or the mouse wheel to keep reading.
|
|
117
|
+
|
|
118
|
+
Change the cycling shortcut with `convo --thinking-key ctrl+g` or set `CONVO_THINKING_KEY=ctrl+g` in `.env` (export it for `--shell`). The command-line flag takes precedence. Shift+Tab and F2 remain available. Choose a Ctrl/Alt letter or F3–F24; essential keys are reserved. `/help` shows the configured shortcut. Only thinking levels supported by the selected account model can be saved.
|
|
119
|
+
|
|
120
|
+
Sessions, completed message pairs, displayed source quotes, and structured answers persist in PostgreSQL. Use `/sessions` after restarting. Resuming uses the saved indexed snapshot, so the original file can have moved or disappeared. Use `/file` again to index changed content. A failed request never saves a partial turn. Account tokens remain in the OS keyring; model/thinking preferences remain in the local account profile.
|
|
121
|
+
|
|
122
|
+
Greetings such as `hello` and acknowledgements such as `thank you` receive a short local reply without retrieval or model calls. Mixed greetings and file questions still use retrieval. Specific questions use hybrid search, reciprocal rank fusion, cross-encoder reranking, and weak-match filtering. Broad file overviews sample sections across the snapshot and rerank their order without applying a QA relevance cutoff.
|
|
123
|
+
|
|
124
|
+
ChatGPT returns JSON sections containing text and citation objects. Python validates the schema, requires citations on answered sections, checks source IDs and exact supporting quotes, then renders readable Markdown with numbered citations. Only cited quotes appear under sources. An empty evidence set returns an insufficient-evidence response without an LLM call. These checks verify structure and quotations; they do not prove every generated claim is entailed by its quote.
|
|
125
|
+
|
|
126
|
+
Supported files: `.txt`, `.md`, `.pdf`, `.docx`, `.csv`, `.json`, `.py`, `.rst`, `.log`. Limits: 10 MB input, 500,000 extracted characters, and 2,000 chunks. LlamaIndex is used only for readers and chunking. The TUI supports terminals down to 50 columns × 18 rows.
|
|
127
|
+
|
|
128
|
+
## Architecture
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
convo/
|
|
132
|
+
├── core/ settings, shared values and UI/backend contracts
|
|
133
|
+
├── ui/ Textual app, commands, file/model/session pickers
|
|
134
|
+
├── llm/ ChatGPT connection, credentials, catalog and Responses transport
|
|
135
|
+
├── pipeline/ chat flow, routing, ingestion, embeddings, reranking and worker
|
|
136
|
+
├── prompts/ answer instructions, context builder and structured schema
|
|
137
|
+
└── db/ SQLAlchemy models, connections, document/search/session queries
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
[ARCHITECTURE.md](ARCHITECTURE.md) explains the files, schema, data flow, tuning, and verification. [changes.md](changes.md) records implementation approaches and validation.
|
|
141
|
+
|
|
142
|
+
## Checks
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
uv run ruff format --check .
|
|
146
|
+
uv run ruff check .
|
|
147
|
+
uv run ty check
|
|
148
|
+
uv run pytest -q
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
PostgreSQL integration checks use a disposable database, enabled explicitly with `CONVO_TEST_DATABASE_URL`. `CONVO_TEST_MODELS=1` also exercises real Qwen/reranking models. See [ARCHITECTURE.md](ARCHITECTURE.md) for these opt-in checks and their prerequisites.
|
|
152
|
+
|
|
153
|
+
## Releasing to PyPI
|
|
154
|
+
|
|
155
|
+
Maintainers should test a release on TestPyPI first, then create a GitHub release. The included `Publish to PyPI` workflow uses PyPI Trusted Publishing, so it does not require a long-lived token in the repository. Configure the repository and `.github/workflows/publish.yml` as a Trusted Publisher in PyPI before publishing.
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
uv build --no-sources
|
|
159
|
+
uv publish --token "$PYPI_TOKEN" # one-time/manual alternative
|
|
160
|
+
```
|