cobalt-cli-linux 0.1.3__tar.gz → 0.1.5__tar.gz
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.
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/PKG-INFO +6 -2
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/README.md +5 -1
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/pyproject.toml +1 -1
- cobalt_cli_linux-0.1.5/src/cobalt_cli_linux/ASCII.txt +5 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/agent.py +15 -2
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/groq_client.py +19 -2
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/history.py +5 -1
- cobalt_cli_linux-0.1.5/src/cobalt_cli_linux/profiles.py +90 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/tui.py +204 -54
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/PKG-INFO +6 -2
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/SOURCES.txt +1 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/tests/test_cli.py +33 -14
- cobalt_cli_linux-0.1.3/src/cobalt_cli_linux/ASCII.txt +0 -5
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/setup.cfg +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/__init__.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/__main__.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/cli.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/config.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/deepseek_client.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/executor.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cobalt-cli-linux
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Agentic Groq-powered CLI assistant for Debian Linux
|
|
5
5
|
Author: Cobalt
|
|
6
6
|
License-Expression: MIT
|
|
@@ -77,7 +77,11 @@ cobalt --interactive
|
|
|
77
77
|
|
|
78
78
|
This launches a fullscreen TUI that feels similar to a professional agent console, with the message stream and a compact prompt area instead of a raw shell prompt.
|
|
79
79
|
|
|
80
|
-
In the interface,
|
|
80
|
+
In the interface, click the `New chat` and `Delete chat` buttons to manage conversations. Press `Escape` to unfocus the prompt; click the prompt or type a character to focus it again. Long prompts wrap inside the prompt box.
|
|
81
|
+
|
|
82
|
+
Assistant reasoning is stored separately and can be expanded by clicking its `thinking` row. Markdown headings, lists, inline backticks, and fenced code are styled in the terminal. Guest profiles are limited to three prompts per hour.
|
|
83
|
+
|
|
84
|
+
The interface includes `Sign in` and `Sign up` buttons for local profiles. Profiles are stored locally; guest mode remains available with the prompt limit.
|
|
81
85
|
|
|
82
86
|
You can also pass arguments directly:
|
|
83
87
|
|
|
@@ -52,7 +52,11 @@ cobalt --interactive
|
|
|
52
52
|
|
|
53
53
|
This launches a fullscreen TUI that feels similar to a professional agent console, with the message stream and a compact prompt area instead of a raw shell prompt.
|
|
54
54
|
|
|
55
|
-
In the interface,
|
|
55
|
+
In the interface, click the `New chat` and `Delete chat` buttons to manage conversations. Press `Escape` to unfocus the prompt; click the prompt or type a character to focus it again. Long prompts wrap inside the prompt box.
|
|
56
|
+
|
|
57
|
+
Assistant reasoning is stored separately and can be expanded by clicking its `thinking` row. Markdown headings, lists, inline backticks, and fenced code are styled in the terminal. Guest profiles are limited to three prompts per hour.
|
|
58
|
+
|
|
59
|
+
The interface includes `Sign in` and `Sign up` buttons for local profiles. Profiles are stored locally; guest mode remains available with the prompt limit.
|
|
56
60
|
|
|
57
61
|
You can also pass arguments directly:
|
|
58
62
|
|
|
@@ -7,6 +7,7 @@ from .config import Settings
|
|
|
7
7
|
from .executor import ShellExecutor
|
|
8
8
|
from .groq_client import GroqClient
|
|
9
9
|
from .history import ConversationHistory
|
|
10
|
+
from .profiles import ProfileStore
|
|
10
11
|
|
|
11
12
|
SYSTEM_PROMPT = """You are Cobalt, an expert Debian Linux assistant and DevOps engineer.
|
|
12
13
|
|
|
@@ -26,6 +27,7 @@ class CobaltAgent:
|
|
|
26
27
|
def __init__(self, settings: Settings | None = None, history: ConversationHistory | None = None) -> None:
|
|
27
28
|
self.settings = settings or Settings()
|
|
28
29
|
self.history = history or ConversationHistory(self.settings.history_path)
|
|
30
|
+
self.profile_store = ProfileStore()
|
|
29
31
|
self.executor = ShellExecutor(self.settings.shell)
|
|
30
32
|
self.client = GroqClient(
|
|
31
33
|
api_key=self.settings.api_key,
|
|
@@ -121,9 +123,11 @@ class CobaltAgent:
|
|
|
121
123
|
self.history.add("assistant", follow_up)
|
|
122
124
|
return follow_up
|
|
123
125
|
|
|
124
|
-
def process_stream(self, prompt: str, on_chunk=None) -> str:
|
|
126
|
+
def process_stream(self, prompt: str, on_chunk=None, on_thinking=None) -> str:
|
|
125
127
|
if not self.settings.api_key_configured:
|
|
126
128
|
raise RuntimeError("Missing Groq API key. Use the built-in key provided by the package, or set GROQ_API_KEY in a local .env file before running the CLI.")
|
|
129
|
+
if not self.profile_store.allow_prompt():
|
|
130
|
+
raise RuntimeError("Guest limit reached: 3 prompts per hour. Create a profile to continue.")
|
|
127
131
|
|
|
128
132
|
if not self.history.messages or self.history.messages[-1].get("role") != "user":
|
|
129
133
|
self.history.add("user", prompt)
|
|
@@ -140,7 +144,14 @@ class CobaltAgent:
|
|
|
140
144
|
else:
|
|
141
145
|
self.history.add("assistant", "")
|
|
142
146
|
|
|
143
|
-
|
|
147
|
+
thinking_buffer = ""
|
|
148
|
+
for event in self.client.chat_completion_stream_events(messages, temperature=self.settings.temperature, max_tokens=self.settings.max_tokens):
|
|
149
|
+
if event["type"] == "thinking":
|
|
150
|
+
thinking_buffer += event["text"]
|
|
151
|
+
if on_thinking is not None:
|
|
152
|
+
on_thinking(event["text"])
|
|
153
|
+
continue
|
|
154
|
+
chunk = event["text"]
|
|
144
155
|
buffer += chunk
|
|
145
156
|
if on_chunk is not None:
|
|
146
157
|
on_chunk(chunk)
|
|
@@ -160,6 +171,8 @@ class CobaltAgent:
|
|
|
160
171
|
|
|
161
172
|
if self.history.messages and self.history.messages[-1].get("role") == "assistant":
|
|
162
173
|
self.history.messages[-1]["content"] = buffer
|
|
174
|
+
if thinking_buffer:
|
|
175
|
+
self.history.messages[-1]["thinking"] = thinking_buffer
|
|
163
176
|
self.history.save()
|
|
164
177
|
else:
|
|
165
178
|
self.history.add("assistant", buffer)
|
|
@@ -11,6 +11,10 @@ class GroqAPIError(RuntimeError):
|
|
|
11
11
|
"""Raised when the Groq API rejects a request."""
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
class GroqStreamEvent(dict[str, str]):
|
|
15
|
+
"""A streamed text fragment categorized as thinking or answer content."""
|
|
16
|
+
|
|
17
|
+
|
|
14
18
|
class GroqClient:
|
|
15
19
|
DEFAULT_MODELS: ClassVar[list[str]] = [
|
|
16
20
|
"llama-3.3-70b-versatile",
|
|
@@ -114,6 +118,16 @@ class GroqClient:
|
|
|
114
118
|
temperature: float = 0.2,
|
|
115
119
|
max_tokens: int = 1024,
|
|
116
120
|
) -> Iterator[str]:
|
|
121
|
+
for event in self.chat_completion_stream_events(messages, temperature, max_tokens):
|
|
122
|
+
if event["type"] == "content":
|
|
123
|
+
yield event["text"]
|
|
124
|
+
|
|
125
|
+
def chat_completion_stream_events(
|
|
126
|
+
self,
|
|
127
|
+
messages: list[dict[str, str]],
|
|
128
|
+
temperature: float = 0.2,
|
|
129
|
+
max_tokens: int = 1024,
|
|
130
|
+
) -> Iterator[GroqStreamEvent]:
|
|
117
131
|
resolved_model = self.resolve_model()
|
|
118
132
|
self.model = resolved_model
|
|
119
133
|
|
|
@@ -154,6 +168,9 @@ class GroqClient:
|
|
|
154
168
|
if not choices:
|
|
155
169
|
continue
|
|
156
170
|
delta = choices[0].get("delta", {})
|
|
171
|
+
reasoning = delta.get("reasoning_content") or delta.get("thinking")
|
|
172
|
+
if isinstance(reasoning, str) and reasoning:
|
|
173
|
+
yield GroqStreamEvent(type="thinking", text=reasoning)
|
|
157
174
|
content = delta.get("content")
|
|
158
|
-
if isinstance(content, str):
|
|
159
|
-
yield content
|
|
175
|
+
if isinstance(content, str) and content:
|
|
176
|
+
yield GroqStreamEvent(type="content", text=content)
|
|
@@ -35,7 +35,11 @@ class ConversationHistory:
|
|
|
35
35
|
data = data.get("messages", [])
|
|
36
36
|
if isinstance(data, list):
|
|
37
37
|
self._messages = [
|
|
38
|
-
{
|
|
38
|
+
{
|
|
39
|
+
"role": str(item.get("role", "user")),
|
|
40
|
+
"content": str(item.get("content", "")),
|
|
41
|
+
**({"thinking": str(item["thinking"])} if item.get("thinking") else {}),
|
|
42
|
+
}
|
|
39
43
|
for item in data
|
|
40
44
|
if isinstance(item, dict)
|
|
41
45
|
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProfileStore:
|
|
10
|
+
def __init__(self, path: str | Path = "~/.cobalt/profiles.json") -> None:
|
|
11
|
+
self.path = Path(path).expanduser()
|
|
12
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
self.data = self._load()
|
|
14
|
+
|
|
15
|
+
def _load(self) -> dict:
|
|
16
|
+
if not self.path.exists():
|
|
17
|
+
return {"profiles": {"guest": {"prompts": []}}, "accounts": {}, "active": "guest"}
|
|
18
|
+
try:
|
|
19
|
+
value = json.loads(self.path.read_text(encoding="utf-8"))
|
|
20
|
+
except json.JSONDecodeError:
|
|
21
|
+
return {"profiles": {"guest": {"prompts": []}}, "accounts": {}, "active": "guest"}
|
|
22
|
+
value.setdefault("profiles", {}).setdefault("guest", {"prompts": []})
|
|
23
|
+
value.setdefault("accounts", {})
|
|
24
|
+
value.setdefault("active", "guest")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
def save(self) -> None:
|
|
28
|
+
self.path.write_text(json.dumps(self.data, indent=2), encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def active(self) -> str:
|
|
32
|
+
return self.data["active"]
|
|
33
|
+
|
|
34
|
+
def create(self, name: str) -> str:
|
|
35
|
+
name = " ".join(name.strip().split())
|
|
36
|
+
if not name:
|
|
37
|
+
raise ValueError("Profile name cannot be empty")
|
|
38
|
+
self.data["profiles"].setdefault(name, {"prompts": []})
|
|
39
|
+
self.data["active"] = name
|
|
40
|
+
self.save()
|
|
41
|
+
return name
|
|
42
|
+
|
|
43
|
+
def sign_up(self, name: str, password: str) -> str:
|
|
44
|
+
name = " ".join(name.strip().split())
|
|
45
|
+
if not name or not password:
|
|
46
|
+
raise ValueError("Name and password are required")
|
|
47
|
+
if name in self.data["accounts"]:
|
|
48
|
+
raise ValueError("Profile already exists")
|
|
49
|
+
digest = hashlib.sha256(password.encode("utf-8")).hexdigest()
|
|
50
|
+
self.data["accounts"][name] = digest
|
|
51
|
+
self.data["profiles"].setdefault(name, {"prompts": []})
|
|
52
|
+
self.data["active"] = name
|
|
53
|
+
self.save()
|
|
54
|
+
return name
|
|
55
|
+
|
|
56
|
+
def sign_in(self, name: str, password: str) -> str:
|
|
57
|
+
digest = hashlib.sha256(password.encode("utf-8")).hexdigest()
|
|
58
|
+
if self.data["accounts"].get(name) != digest:
|
|
59
|
+
raise ValueError("Invalid profile name or password")
|
|
60
|
+
self.data["active"] = name
|
|
61
|
+
self.save()
|
|
62
|
+
return name
|
|
63
|
+
|
|
64
|
+
def delete(self, name: str | None = None) -> bool:
|
|
65
|
+
name = name or self.active
|
|
66
|
+
if name == "guest" or name not in self.data["profiles"]:
|
|
67
|
+
return False
|
|
68
|
+
del self.data["profiles"][name]
|
|
69
|
+
self.data["active"] = "guest"
|
|
70
|
+
self.save()
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
def allow_prompt(self) -> bool:
|
|
74
|
+
if self.active != "guest":
|
|
75
|
+
return True
|
|
76
|
+
now = datetime.now(timezone.utc)
|
|
77
|
+
cutoff = now - timedelta(hours=1)
|
|
78
|
+
prompts = [
|
|
79
|
+
datetime.fromisoformat(value)
|
|
80
|
+
for value in self.data["profiles"]["guest"].get("prompts", [])
|
|
81
|
+
if datetime.fromisoformat(value) >= cutoff
|
|
82
|
+
]
|
|
83
|
+
if len(prompts) >= 3:
|
|
84
|
+
self.data["profiles"]["guest"]["prompts"] = [value.isoformat() for value in prompts]
|
|
85
|
+
self.save()
|
|
86
|
+
return False
|
|
87
|
+
prompts.append(now)
|
|
88
|
+
self.data["profiles"]["guest"]["prompts"] = [value.isoformat() for value in prompts]
|
|
89
|
+
self.save()
|
|
90
|
+
return True
|
|
@@ -7,14 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from .agent import CobaltAgent
|
|
9
9
|
from .history import ChatStore
|
|
10
|
-
|
|
11
|
-
COBALT_CLI_ART = (
|
|
12
|
-
" ____ ___ ____ _ _ _____ ____ _ ___ ",
|
|
13
|
-
" / ___/ _ \\| __ ) / \\ | | |_ _| / ___| | |_ _|",
|
|
14
|
-
" | | | | | | _ \\ / _ \\ | | | | | | | | | | ",
|
|
15
|
-
" | |__| |_| | |_) / ___ \\| |___| | | |___| |___ | | ",
|
|
16
|
-
" \\____\\___/|____/_/ \\_\\_____|_| \\____|_____|___|",
|
|
17
|
-
)
|
|
10
|
+
from .profiles import ProfileStore
|
|
18
11
|
|
|
19
12
|
|
|
20
13
|
class CobaltTUI:
|
|
@@ -31,6 +24,18 @@ class CobaltTUI:
|
|
|
31
24
|
self.history = self.current_chat
|
|
32
25
|
self.saved_chats = self.chat_files
|
|
33
26
|
self.input_focused = True
|
|
27
|
+
self.ascii_lines = self._load_ascii_art()
|
|
28
|
+
self.thinking_expanded: set[int] = set()
|
|
29
|
+
self.profile_store = ProfileStore()
|
|
30
|
+
self.auth_view: str | None = None
|
|
31
|
+
self.auth_field = 0
|
|
32
|
+
self.auth_values = ["", ""]
|
|
33
|
+
|
|
34
|
+
def _load_ascii_art(self) -> list[str]:
|
|
35
|
+
ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
|
|
36
|
+
if not ascii_path.exists():
|
|
37
|
+
return ["Cobalt CLI"]
|
|
38
|
+
return [line.rstrip() for line in ascii_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
34
39
|
|
|
35
40
|
def _refresh_chat_files(self) -> None:
|
|
36
41
|
self.chat_files = self.chat_store.list_chats()
|
|
@@ -58,8 +63,81 @@ class CobaltTUI:
|
|
|
58
63
|
title = " ".join(words[:4]).strip()
|
|
59
64
|
return title.title() if title else "New chat"
|
|
60
65
|
|
|
61
|
-
def
|
|
62
|
-
|
|
66
|
+
def _button_regions(self, width: int) -> dict[str, tuple[int, int, int, int]]:
|
|
67
|
+
delete_label = "[ Delete chat ]"
|
|
68
|
+
new_label = "[ New chat ]"
|
|
69
|
+
delete_x = max(1, width - len(delete_label) - 2)
|
|
70
|
+
new_x = max(1, delete_x - len(new_label) - 2)
|
|
71
|
+
signup_label = "[ Sign up ]"
|
|
72
|
+
signin_label = "[ Sign in ]"
|
|
73
|
+
signin_x = max(1, new_x - len(signin_label) - 2)
|
|
74
|
+
signup_x = max(1, signin_x - len(signup_label) - 2)
|
|
75
|
+
return {
|
|
76
|
+
"new": (new_x, 0, len(new_label), 1),
|
|
77
|
+
"delete": (delete_x, 0, len(delete_label), 1),
|
|
78
|
+
"signin": (signin_x, 0, len(signin_label), 1),
|
|
79
|
+
"signup": (signup_x, 0, len(signup_label), 1),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def _handle_button_click(self, x: int, y: int, width: int | None = None) -> str | None:
|
|
83
|
+
screen_width = width if width is not None else curses.COLS
|
|
84
|
+
for name, (button_x, button_y, button_width, button_height) in self._button_regions(screen_width).items():
|
|
85
|
+
if button_x <= x < button_x + button_width and button_y <= y < button_y + button_height:
|
|
86
|
+
return name
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def _start_new_chat(self) -> None:
|
|
90
|
+
self.current_chat = self.chat_store.new_chat(title="New chat")
|
|
91
|
+
self.history = self.current_chat
|
|
92
|
+
self.selected_chat_path = str(self.current_chat.path)
|
|
93
|
+
self.chat_files = self.chat_store.list_chats()
|
|
94
|
+
|
|
95
|
+
def _delete_selected_chat(self) -> None:
|
|
96
|
+
if not self.selected_chat_path:
|
|
97
|
+
return
|
|
98
|
+
target = Path(self.selected_chat_path)
|
|
99
|
+
if not target.exists():
|
|
100
|
+
return
|
|
101
|
+
self.chat_store.delete_chat(target)
|
|
102
|
+
self.chat_files = self.chat_store.list_chats()
|
|
103
|
+
if self.chat_files:
|
|
104
|
+
self.selected_chat_path = str(self.chat_files[-1])
|
|
105
|
+
self.history = self.chat_store.load_chat(self.selected_chat_path)
|
|
106
|
+
self.current_chat = self.history
|
|
107
|
+
else:
|
|
108
|
+
self._start_new_chat()
|
|
109
|
+
|
|
110
|
+
def _open_auth(self, view: str) -> None:
|
|
111
|
+
self.auth_view = view
|
|
112
|
+
self.auth_field = 0
|
|
113
|
+
self.auth_values = ["", ""]
|
|
114
|
+
self.input_focused = True
|
|
115
|
+
|
|
116
|
+
def _submit_auth(self) -> None:
|
|
117
|
+
try:
|
|
118
|
+
if self.auth_view == "signup":
|
|
119
|
+
self.profile_store.sign_up(*self.auth_values)
|
|
120
|
+
elif self.auth_view == "signin":
|
|
121
|
+
self.profile_store.sign_in(*self.auth_values)
|
|
122
|
+
self.auth_view = None
|
|
123
|
+
self.auth_values = ["", ""]
|
|
124
|
+
except (ValueError, OSError) as exc:
|
|
125
|
+
self.auth_values[1] = str(exc)
|
|
126
|
+
|
|
127
|
+
def _render_auth(self, stdscr, height: int, width: int) -> None:
|
|
128
|
+
title = "Create profile" if self.auth_view == "signup" else "Sign in"
|
|
129
|
+
fields = ("Profile name", "Password")
|
|
130
|
+
panel_width = min(64, max(36, width - 8))
|
|
131
|
+
panel_x = max(2, (width - panel_width) // 2)
|
|
132
|
+
panel_y = max(3, (height - 12) // 2)
|
|
133
|
+
self._draw_box(stdscr, panel_y, panel_x, 10, panel_width, f" {title} ")
|
|
134
|
+
for index, label in enumerate(fields):
|
|
135
|
+
value = self.auth_values[index]
|
|
136
|
+
if index == 1 and value and not value.startswith("Invalid"):
|
|
137
|
+
value = "*" * len(value)
|
|
138
|
+
marker = "> " if index == self.auth_field else " "
|
|
139
|
+
stdscr.addstr(panel_y + 2 + index * 2, panel_x + 3, f"{marker}{label}: {value}"[: panel_width - 6])
|
|
140
|
+
stdscr.addstr(panel_y + 7, panel_x + 3, "Enter: next/submit | Escape: back"[: panel_width - 6], curses.A_DIM)
|
|
63
141
|
|
|
64
142
|
def _input_box_geometry(self, main_x: int, main_y: int, main_w: int, main_h: int, input_text: str) -> tuple[int, int, int, int, list[str]]:
|
|
65
143
|
box_width = max(20, main_w - 4)
|
|
@@ -100,6 +178,36 @@ class CobaltTUI:
|
|
|
100
178
|
lines.extend(textwrap.wrap(paragraph, width=max(15, width), break_long_words=False, break_on_hyphens=False))
|
|
101
179
|
return lines
|
|
102
180
|
|
|
181
|
+
def _format_message_lines(self, message: dict[str, str], width: int, message_index: int) -> list[tuple[str, int]]:
|
|
182
|
+
role = message.get("role", "user").lower()
|
|
183
|
+
lines: list[tuple[str, int]] = []
|
|
184
|
+
thinking = message.get("thinking", "")
|
|
185
|
+
if role == "assistant" and thinking:
|
|
186
|
+
marker = "[-] thinking (click to expand)" if message_index not in self.thinking_expanded else "[+] thinking"
|
|
187
|
+
lines.append((marker, curses.A_DIM))
|
|
188
|
+
if message_index in self.thinking_expanded:
|
|
189
|
+
for line in self._compose_lines(thinking, max(12, width - 5)):
|
|
190
|
+
lines.append((f" {line}", curses.A_DIM))
|
|
191
|
+
|
|
192
|
+
in_code = False
|
|
193
|
+
has_body = False
|
|
194
|
+
for raw_line in message.get("content", "").splitlines() or [""]:
|
|
195
|
+
if raw_line.strip().startswith("```"):
|
|
196
|
+
in_code = not in_code
|
|
197
|
+
continue
|
|
198
|
+
attr = curses.A_REVERSE if in_code else curses.A_NORMAL
|
|
199
|
+
prefix = f"{role} " if not has_body else " "
|
|
200
|
+
if raw_line.startswith("#") or raw_line.lstrip().startswith(('-', '*')):
|
|
201
|
+
attr |= curses.A_BOLD
|
|
202
|
+
if "`" in raw_line:
|
|
203
|
+
raw_line = raw_line.replace("`", "")
|
|
204
|
+
attr |= curses.A_REVERSE
|
|
205
|
+
for line in self._compose_lines(raw_line, max(12, width - len(prefix))):
|
|
206
|
+
lines.append((f"{prefix}{line}" if prefix.strip() else line, attr))
|
|
207
|
+
prefix = " "
|
|
208
|
+
has_body = True
|
|
209
|
+
return lines
|
|
210
|
+
|
|
103
211
|
def _draw_box(self, stdscr, top: int, left: int, height: int, width: int, title: str | None = None) -> None:
|
|
104
212
|
if height < 3 or width < 4:
|
|
105
213
|
return
|
|
@@ -120,16 +228,23 @@ class CobaltTUI:
|
|
|
120
228
|
|
|
121
229
|
header = " Cobalt Code | terminal agent "
|
|
122
230
|
stdscr.addstr(0, 1, header[: max(0, width - 2)], curses.A_BOLD)
|
|
231
|
+
for name, (button_x, button_y, button_width, _) in self._button_regions(width).items():
|
|
232
|
+
labels = {"new": "[ New chat ]", "delete": "[ Delete chat ]", "signin": "[ Sign in ]", "signup": "[ Sign up ]"}
|
|
233
|
+
label = labels[name]
|
|
234
|
+
stdscr.addstr(button_y, button_x, label[:button_width], curses.A_REVERSE if name == "new" else curses.A_NORMAL)
|
|
235
|
+
|
|
236
|
+
if self.auth_view:
|
|
237
|
+
self._render_auth(stdscr, height, width)
|
|
238
|
+
stdscr.refresh()
|
|
239
|
+
return
|
|
123
240
|
|
|
124
241
|
sidebar_w = min(26, max(20, width // 4))
|
|
125
242
|
sidebar_x = 1
|
|
126
|
-
for index, line in enumerate(COBALT_CLI_ART):
|
|
127
|
-
x = max(0, (width - len(line)) // 2)
|
|
128
|
-
stdscr.addstr(index, x, line[: max(0, width - x)])
|
|
129
|
-
|
|
130
243
|
sidebar_y = 6
|
|
131
244
|
sidebar_h = max(8, height - 9)
|
|
132
245
|
self._draw_box(stdscr, sidebar_y, sidebar_x, sidebar_h, sidebar_w, " Chats ")
|
|
246
|
+
profile_line = f"profile: {self.profile_store.active}"
|
|
247
|
+
stdscr.addstr(sidebar_y + 1, sidebar_x + 2, profile_line[: max(0, sidebar_w - 4)], curses.A_DIM)
|
|
133
248
|
|
|
134
249
|
chat_files = self.chat_store.list_chats()[-8:]
|
|
135
250
|
for idx, chat_path in enumerate(chat_files):
|
|
@@ -151,23 +266,25 @@ class CobaltTUI:
|
|
|
151
266
|
self._draw_box(stdscr, main_y, main_x, main_h, main_w, f" {self.history.title or 'conversation'} ")
|
|
152
267
|
|
|
153
268
|
box_x, box_y, box_width, box_height, input_lines = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
|
|
154
|
-
if self.history.messages:
|
|
155
|
-
|
|
156
|
-
for
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
269
|
+
if not self.history.messages:
|
|
270
|
+
art_start = main_y + 1
|
|
271
|
+
for index, line in enumerate(self.ascii_lines):
|
|
272
|
+
y = art_start + index
|
|
273
|
+
x = main_x + max(0, (main_w - len(line)) // 2)
|
|
274
|
+
if y < box_y:
|
|
275
|
+
stdscr.addstr(y, x, line[: max(0, main_w - 2)])
|
|
276
|
+
else:
|
|
277
|
+
formatted_rows: list[tuple[str, int, int]] = []
|
|
278
|
+
for message_index, message in enumerate(self.history.latest(18)):
|
|
279
|
+
formatted_rows.extend((line, attr, message_index) for line, attr in self._format_message_lines(message, main_w - 8, message_index))
|
|
280
|
+
|
|
281
|
+
visible = formatted_rows[-max(1, box_y - main_y - 2):]
|
|
282
|
+
for idx, (line, attr, _) in enumerate(visible[: main_h - 4]):
|
|
166
283
|
y = main_y + 1 + idx
|
|
167
284
|
if y >= box_y:
|
|
168
285
|
break
|
|
169
286
|
clipped = line[: max(0, main_w - 4)]
|
|
170
|
-
stdscr.addstr(y, main_x + 2, clipped)
|
|
287
|
+
stdscr.addstr(y, main_x + 2, clipped, attr)
|
|
171
288
|
|
|
172
289
|
self._draw_input_box(stdscr, box_x, box_y, box_width, box_height, input_lines, self.input_focused)
|
|
173
290
|
|
|
@@ -190,7 +307,13 @@ class CobaltTUI:
|
|
|
190
307
|
|
|
191
308
|
agent = CobaltAgent(settings=self.settings)
|
|
192
309
|
agent.history = self.history
|
|
193
|
-
|
|
310
|
+
def on_thinking(chunk: str) -> None:
|
|
311
|
+
if self.history.messages and self.history.messages[-1]["role"] == "assistant":
|
|
312
|
+
self.history.messages[-1]["thinking"] = self.history.messages[-1].get("thinking", "") + chunk
|
|
313
|
+
self.history.save()
|
|
314
|
+
self._render(stdscr, input_text, "")
|
|
315
|
+
|
|
316
|
+
response = agent.process_stream(input_text.strip(), on_chunk=on_chunk, on_thinking=on_thinking)
|
|
194
317
|
self.history = agent.history
|
|
195
318
|
self.current_chat = self.history
|
|
196
319
|
self.saved_chats = self.chat_store.list_chats()
|
|
@@ -230,6 +353,27 @@ class CobaltTUI:
|
|
|
230
353
|
box_x, box_y, box_width, box_height, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
|
|
231
354
|
return box_x <= x < box_x + box_width and box_y <= y < box_y + box_height
|
|
232
355
|
|
|
356
|
+
def _handle_thinking_click(self, x: int, y: int) -> bool:
|
|
357
|
+
height, width = curses.LINES, curses.COLS
|
|
358
|
+
sidebar_w = min(26, max(20, width // 4))
|
|
359
|
+
main_x, main_y = 3 + sidebar_w, 6
|
|
360
|
+
main_w, main_h = max(20, width - main_x - 2), max(8, height - 9)
|
|
361
|
+
_, box_y, _, _, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, "")
|
|
362
|
+
rows: list[tuple[str, int]] = []
|
|
363
|
+
for index, message in enumerate(self.history.latest(18)):
|
|
364
|
+
rows.extend(self._format_message_lines(message, main_w - 8, index))
|
|
365
|
+
for row_index, (line, _) in enumerate(rows):
|
|
366
|
+
if main_y + 1 + row_index >= box_y:
|
|
367
|
+
break
|
|
368
|
+
if line.startswith(("[-] thinking", "[+] thinking")) and main_y + 1 + row_index == y:
|
|
369
|
+
message_index = next(index for index, message in enumerate(self.history.latest(18)) if message.get("thinking") and self._format_message_lines(message, main_w - 8, index)[0][0] == line)
|
|
370
|
+
if message_index in self.thinking_expanded:
|
|
371
|
+
self.thinking_expanded.remove(message_index)
|
|
372
|
+
else:
|
|
373
|
+
self.thinking_expanded.add(message_index)
|
|
374
|
+
return True
|
|
375
|
+
return False
|
|
376
|
+
|
|
233
377
|
def run(self) -> int:
|
|
234
378
|
def _main(stdscr):
|
|
235
379
|
curses.curs_set(0)
|
|
@@ -251,9 +395,25 @@ class CobaltTUI:
|
|
|
251
395
|
|
|
252
396
|
if ch == curses.KEY_MOUSE:
|
|
253
397
|
_, x, y, _, button_state = curses.getmouse()
|
|
398
|
+
button = self._handle_button_click(x, y, stdscr.getmaxyx()[1]) if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) else None
|
|
399
|
+
if button in ("signin", "signup"):
|
|
400
|
+
self._open_auth(button)
|
|
401
|
+
continue
|
|
402
|
+
if button == "new":
|
|
403
|
+
self._start_new_chat()
|
|
404
|
+
input_text = ""
|
|
405
|
+
self.input_focused = True
|
|
406
|
+
continue
|
|
407
|
+
if button == "delete":
|
|
408
|
+
self._delete_selected_chat()
|
|
409
|
+
input_text = ""
|
|
410
|
+
self.input_focused = False
|
|
411
|
+
continue
|
|
254
412
|
if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._click_is_in_input(x, y, input_text):
|
|
255
413
|
self.input_focused = True
|
|
256
414
|
continue
|
|
415
|
+
if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._handle_thinking_click(x, y):
|
|
416
|
+
continue
|
|
257
417
|
if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._handle_mouse_click(x, y):
|
|
258
418
|
status = ""
|
|
259
419
|
input_text = ""
|
|
@@ -263,35 +423,25 @@ class CobaltTUI:
|
|
|
263
423
|
if ch == 3:
|
|
264
424
|
break
|
|
265
425
|
if ch == 27:
|
|
426
|
+
if self.auth_view:
|
|
427
|
+
self.auth_view = None
|
|
428
|
+
self.auth_values = ["", ""]
|
|
429
|
+
continue
|
|
266
430
|
self.input_focused = False
|
|
267
431
|
continue
|
|
268
|
-
if self.
|
|
269
|
-
if ch in (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
self.chat_files = self.chat_store.list_chats()
|
|
275
|
-
input_text = ""
|
|
276
|
-
status = ""
|
|
432
|
+
if self.auth_view:
|
|
433
|
+
if ch in (10, 13, curses.KEY_ENTER):
|
|
434
|
+
if self.auth_field == 0:
|
|
435
|
+
self.auth_field = 1
|
|
436
|
+
else:
|
|
437
|
+
self._submit_auth()
|
|
277
438
|
continue
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if self.chat_files:
|
|
285
|
-
self.selected_chat_path = str(self.chat_files[-1])
|
|
286
|
-
self.history = self.chat_store.load_chat(self.selected_chat_path)
|
|
287
|
-
self.current_chat = self.history
|
|
288
|
-
else:
|
|
289
|
-
self.current_chat = self.chat_store.new_chat(title="New chat")
|
|
290
|
-
self.history = self.current_chat
|
|
291
|
-
self.selected_chat_path = str(self.current_chat.path)
|
|
292
|
-
input_text = ""
|
|
293
|
-
status = ""
|
|
294
|
-
continue
|
|
439
|
+
if ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
440
|
+
self.auth_values[self.auth_field] = self.auth_values[self.auth_field][:-1]
|
|
441
|
+
continue
|
|
442
|
+
if 32 <= ch <= 126:
|
|
443
|
+
self.auth_values[self.auth_field] += chr(ch)
|
|
444
|
+
continue
|
|
295
445
|
if ch in (10, 13, curses.KEY_ENTER):
|
|
296
446
|
if input_text.strip():
|
|
297
447
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cobalt-cli-linux
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Agentic Groq-powered CLI assistant for Debian Linux
|
|
5
5
|
Author: Cobalt
|
|
6
6
|
License-Expression: MIT
|
|
@@ -77,7 +77,11 @@ cobalt --interactive
|
|
|
77
77
|
|
|
78
78
|
This launches a fullscreen TUI that feels similar to a professional agent console, with the message stream and a compact prompt area instead of a raw shell prompt.
|
|
79
79
|
|
|
80
|
-
In the interface,
|
|
80
|
+
In the interface, click the `New chat` and `Delete chat` buttons to manage conversations. Press `Escape` to unfocus the prompt; click the prompt or type a character to focus it again. Long prompts wrap inside the prompt box.
|
|
81
|
+
|
|
82
|
+
Assistant reasoning is stored separately and can be expanded by clicking its `thinking` row. Markdown headings, lists, inline backticks, and fenced code are styled in the terminal. Guest profiles are limited to three prompts per hour.
|
|
83
|
+
|
|
84
|
+
The interface includes `Sign in` and `Sign up` buttons for local profiles. Profiles are stored locally; guest mode remains available with the prompt limit.
|
|
81
85
|
|
|
82
86
|
You can also pass arguments directly:
|
|
83
87
|
|
|
@@ -10,6 +10,7 @@ src/cobalt_cli_linux/deepseek_client.py
|
|
|
10
10
|
src/cobalt_cli_linux/executor.py
|
|
11
11
|
src/cobalt_cli_linux/groq_client.py
|
|
12
12
|
src/cobalt_cli_linux/history.py
|
|
13
|
+
src/cobalt_cli_linux/profiles.py
|
|
13
14
|
src/cobalt_cli_linux/tui.py
|
|
14
15
|
src/cobalt_cli_linux.egg-info/PKG-INFO
|
|
15
16
|
src/cobalt_cli_linux.egg-info/SOURCES.txt
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
3
5
|
from cobalt_cli_linux import cli
|
|
4
6
|
from cobalt_cli_linux.agent import CobaltAgent
|
|
5
7
|
from cobalt_cli_linux.config import Settings
|
|
6
8
|
from cobalt_cli_linux.executor import ShellExecutor
|
|
7
9
|
from cobalt_cli_linux.groq_client import GroqClient
|
|
8
10
|
from cobalt_cli_linux.history import ChatStore, ConversationHistory
|
|
11
|
+
from cobalt_cli_linux.profiles import ProfileStore
|
|
9
12
|
from cobalt_cli_linux.tui import CobaltTUI
|
|
10
13
|
|
|
11
14
|
|
|
@@ -46,22 +49,15 @@ def test_chat_store_delete_chat(tmp_path: Path) -> None:
|
|
|
46
49
|
assert store.list_chats()[0].name == second.path.name
|
|
47
50
|
|
|
48
51
|
|
|
49
|
-
def
|
|
52
|
+
def test_tui_buttons_replace_chat_shortcuts() -> None:
|
|
50
53
|
tui = CobaltTUI(settings=Settings())
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
assert tui._should_handle_shortcut(4) is True
|
|
54
|
-
assert tui._should_handle_shortcut(ord("n")) is True
|
|
55
|
-
assert tui._should_handle_shortcut(ord("d")) is True
|
|
56
|
-
assert tui._should_handle_shortcut(ord("q")) is False
|
|
57
|
-
assert tui._should_handle_shortcut(ord("n"), True) is False
|
|
58
|
-
assert tui._should_handle_shortcut(ord("d"), True) is False
|
|
59
|
-
|
|
55
|
+
buttons = tui._button_regions(120)
|
|
60
56
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assert tui.
|
|
57
|
+
assert buttons["new"][2] == len("[ New chat ]")
|
|
58
|
+
assert buttons["delete"][2] == len("[ Delete chat ]")
|
|
59
|
+
assert tui._handle_button_click(buttons["new"][0], buttons["new"][1], 120) == "new"
|
|
60
|
+
assert tui._handle_button_click(buttons["delete"][0], buttons["delete"][1], 120) == "delete"
|
|
65
61
|
|
|
66
62
|
|
|
67
63
|
def test_no_prompt_launches_tui(monkeypatch) -> None:
|
|
@@ -126,10 +122,33 @@ def test_stream_error_reads_response_before_inspecting_body(monkeypatch) -> None
|
|
|
126
122
|
raise AssertionError("Expected streamed API error")
|
|
127
123
|
|
|
128
124
|
|
|
125
|
+
def test_profiles_create_delete_and_guest_limit(tmp_path: Path) -> None:
|
|
126
|
+
store = ProfileStore(tmp_path / "profiles.json")
|
|
127
|
+
|
|
128
|
+
assert store.allow_prompt() is True
|
|
129
|
+
assert store.allow_prompt() is True
|
|
130
|
+
assert store.allow_prompt() is True
|
|
131
|
+
assert store.allow_prompt() is False
|
|
132
|
+
assert store.create("personal") == "personal"
|
|
133
|
+
assert store.allow_prompt() is True
|
|
134
|
+
assert store.delete("personal") is True
|
|
135
|
+
assert store.active == "guest"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_profiles_sign_up_and_sign_in(tmp_path: Path) -> None:
|
|
139
|
+
store = ProfileStore(tmp_path / "profiles.json")
|
|
140
|
+
|
|
141
|
+
assert store.sign_up("Ada", "secret") == "Ada"
|
|
142
|
+
assert store.sign_in("Ada", "secret") == "Ada"
|
|
143
|
+
with pytest.raises(ValueError):
|
|
144
|
+
store.sign_in("Ada", "wrong")
|
|
145
|
+
|
|
146
|
+
|
|
129
147
|
def test_tui_empty_chat_does_not_load_prompt_art_as_body_content(tmp_path: Path) -> None:
|
|
130
148
|
tui = CobaltTUI(settings=Settings())
|
|
131
149
|
|
|
132
|
-
assert
|
|
150
|
+
assert tui.ascii_lines
|
|
151
|
+
assert tui.ascii_lines[0].strip().startswith("____")
|
|
133
152
|
|
|
134
153
|
|
|
135
154
|
def test_write_file_tool_creates_and_verifies_file(tmp_path: Path) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/requires.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/top_level.txt
RENAMED
|
File without changes
|