cobalt-cli-linux 0.1.2__tar.gz → 0.1.4__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.2 → cobalt_cli_linux-0.1.4}/PKG-INFO +4 -2
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/README.md +3 -1
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/pyproject.toml +1 -1
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/agent.py +15 -2
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/groq_client.py +19 -2
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/history.py +5 -1
- cobalt_cli_linux-0.1.4/src/cobalt_cli_linux/profiles.py +67 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/tui.py +144 -61
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/PKG-INFO +4 -2
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/SOURCES.txt +1 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/tests/test_cli.py +27 -13
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/setup.cfg +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/ASCII.txt +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/__init__.py +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/__main__.py +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/cli.py +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/config.py +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/deepseek_client.py +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/executor.py +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
- {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/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.4
|
|
4
4
|
Summary: Agentic Groq-powered CLI assistant for Debian Linux
|
|
5
5
|
Author: Cobalt
|
|
6
6
|
License-Expression: MIT
|
|
@@ -77,7 +77,9 @@ 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.
|
|
81
83
|
|
|
82
84
|
You can also pass arguments directly:
|
|
83
85
|
|
|
@@ -52,7 +52,9 @@ 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.
|
|
56
58
|
|
|
57
59
|
You can also pass arguments directly:
|
|
58
60
|
|
|
@@ -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,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProfileStore:
|
|
9
|
+
def __init__(self, path: str | Path = "~/.cobalt/profiles.json") -> None:
|
|
10
|
+
self.path = Path(path).expanduser()
|
|
11
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
self.data = self._load()
|
|
13
|
+
|
|
14
|
+
def _load(self) -> dict:
|
|
15
|
+
if not self.path.exists():
|
|
16
|
+
return {"profiles": {"guest": {"prompts": []}}, "active": "guest"}
|
|
17
|
+
try:
|
|
18
|
+
value = json.loads(self.path.read_text(encoding="utf-8"))
|
|
19
|
+
except json.JSONDecodeError:
|
|
20
|
+
return {"profiles": {"guest": {"prompts": []}}, "active": "guest"}
|
|
21
|
+
value.setdefault("profiles", {}).setdefault("guest", {"prompts": []})
|
|
22
|
+
value.setdefault("active", "guest")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
def save(self) -> None:
|
|
26
|
+
self.path.write_text(json.dumps(self.data, indent=2), encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def active(self) -> str:
|
|
30
|
+
return self.data["active"]
|
|
31
|
+
|
|
32
|
+
def create(self, name: str) -> str:
|
|
33
|
+
name = " ".join(name.strip().split())
|
|
34
|
+
if not name:
|
|
35
|
+
raise ValueError("Profile name cannot be empty")
|
|
36
|
+
self.data["profiles"].setdefault(name, {"prompts": []})
|
|
37
|
+
self.data["active"] = name
|
|
38
|
+
self.save()
|
|
39
|
+
return name
|
|
40
|
+
|
|
41
|
+
def delete(self, name: str | None = None) -> bool:
|
|
42
|
+
name = name or self.active
|
|
43
|
+
if name == "guest" or name not in self.data["profiles"]:
|
|
44
|
+
return False
|
|
45
|
+
del self.data["profiles"][name]
|
|
46
|
+
self.data["active"] = "guest"
|
|
47
|
+
self.save()
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
def allow_prompt(self) -> bool:
|
|
51
|
+
if self.active != "guest":
|
|
52
|
+
return True
|
|
53
|
+
now = datetime.now(timezone.utc)
|
|
54
|
+
cutoff = now - timedelta(hours=1)
|
|
55
|
+
prompts = [
|
|
56
|
+
datetime.fromisoformat(value)
|
|
57
|
+
for value in self.data["profiles"]["guest"].get("prompts", [])
|
|
58
|
+
if datetime.fromisoformat(value) >= cutoff
|
|
59
|
+
]
|
|
60
|
+
if len(prompts) >= 3:
|
|
61
|
+
self.data["profiles"]["guest"]["prompts"] = [value.isoformat() for value in prompts]
|
|
62
|
+
self.save()
|
|
63
|
+
return False
|
|
64
|
+
prompts.append(now)
|
|
65
|
+
self.data["profiles"]["guest"]["prompts"] = [value.isoformat() for value in prompts]
|
|
66
|
+
self.save()
|
|
67
|
+
return True
|
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from .agent import CobaltAgent
|
|
9
9
|
from .history import ChatStore
|
|
10
|
+
from .profiles import ProfileStore
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class CobaltTUI:
|
|
@@ -22,15 +23,15 @@ class CobaltTUI:
|
|
|
22
23
|
self.current_chat = self.chat_store.load_chat(self.selected_chat_path)
|
|
23
24
|
self.history = self.current_chat
|
|
24
25
|
self.saved_chats = self.chat_files
|
|
25
|
-
self.ascii_lines = self._load_ascii_art()
|
|
26
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()
|
|
27
30
|
|
|
28
31
|
def _load_ascii_art(self) -> list[str]:
|
|
29
|
-
ascii_path = Path(__file__).
|
|
30
|
-
if not ascii_path.exists():
|
|
31
|
-
ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
|
|
32
|
+
ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
|
|
32
33
|
if not ascii_path.exists():
|
|
33
|
-
return ["
|
|
34
|
+
return ["Cobalt CLI"]
|
|
34
35
|
return [line.rstrip() for line in ascii_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
35
36
|
|
|
36
37
|
def _refresh_chat_files(self) -> None:
|
|
@@ -59,8 +60,43 @@ class CobaltTUI:
|
|
|
59
60
|
title = " ".join(words[:4]).strip()
|
|
60
61
|
return title.title() if title else "New chat"
|
|
61
62
|
|
|
62
|
-
def
|
|
63
|
-
|
|
63
|
+
def _button_regions(self, width: int) -> dict[str, tuple[int, int, int, int]]:
|
|
64
|
+
delete_label = "[ Delete chat ]"
|
|
65
|
+
new_label = "[ New chat ]"
|
|
66
|
+
delete_x = max(1, width - len(delete_label) - 2)
|
|
67
|
+
new_x = max(1, delete_x - len(new_label) - 2)
|
|
68
|
+
return {
|
|
69
|
+
"new": (new_x, 0, len(new_label), 1),
|
|
70
|
+
"delete": (delete_x, 0, len(delete_label), 1),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def _handle_button_click(self, x: int, y: int, width: int | None = None) -> str | None:
|
|
74
|
+
screen_width = width if width is not None else curses.COLS
|
|
75
|
+
for name, (button_x, button_y, button_width, button_height) in self._button_regions(screen_width).items():
|
|
76
|
+
if button_x <= x < button_x + button_width and button_y <= y < button_y + button_height:
|
|
77
|
+
return name
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def _start_new_chat(self) -> None:
|
|
81
|
+
self.current_chat = self.chat_store.new_chat(title="New chat")
|
|
82
|
+
self.history = self.current_chat
|
|
83
|
+
self.selected_chat_path = str(self.current_chat.path)
|
|
84
|
+
self.chat_files = self.chat_store.list_chats()
|
|
85
|
+
|
|
86
|
+
def _delete_selected_chat(self) -> None:
|
|
87
|
+
if not self.selected_chat_path:
|
|
88
|
+
return
|
|
89
|
+
target = Path(self.selected_chat_path)
|
|
90
|
+
if not target.exists():
|
|
91
|
+
return
|
|
92
|
+
self.chat_store.delete_chat(target)
|
|
93
|
+
self.chat_files = self.chat_store.list_chats()
|
|
94
|
+
if self.chat_files:
|
|
95
|
+
self.selected_chat_path = str(self.chat_files[-1])
|
|
96
|
+
self.history = self.chat_store.load_chat(self.selected_chat_path)
|
|
97
|
+
self.current_chat = self.history
|
|
98
|
+
else:
|
|
99
|
+
self._start_new_chat()
|
|
64
100
|
|
|
65
101
|
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]]:
|
|
66
102
|
box_width = max(20, main_w - 4)
|
|
@@ -101,6 +137,36 @@ class CobaltTUI:
|
|
|
101
137
|
lines.extend(textwrap.wrap(paragraph, width=max(15, width), break_long_words=False, break_on_hyphens=False))
|
|
102
138
|
return lines
|
|
103
139
|
|
|
140
|
+
def _format_message_lines(self, message: dict[str, str], width: int, message_index: int) -> list[tuple[str, int]]:
|
|
141
|
+
role = message.get("role", "user").lower()
|
|
142
|
+
lines: list[tuple[str, int]] = []
|
|
143
|
+
thinking = message.get("thinking", "")
|
|
144
|
+
if role == "assistant" and thinking:
|
|
145
|
+
marker = "[-] thinking (click to expand)" if message_index not in self.thinking_expanded else "[+] thinking"
|
|
146
|
+
lines.append((marker, curses.A_DIM))
|
|
147
|
+
if message_index in self.thinking_expanded:
|
|
148
|
+
for line in self._compose_lines(thinking, max(12, width - 5)):
|
|
149
|
+
lines.append((f" {line}", curses.A_DIM))
|
|
150
|
+
|
|
151
|
+
in_code = False
|
|
152
|
+
has_body = False
|
|
153
|
+
for raw_line in message.get("content", "").splitlines() or [""]:
|
|
154
|
+
if raw_line.strip().startswith("```"):
|
|
155
|
+
in_code = not in_code
|
|
156
|
+
continue
|
|
157
|
+
attr = curses.A_REVERSE if in_code else curses.A_NORMAL
|
|
158
|
+
prefix = f"{role} " if not has_body else " "
|
|
159
|
+
if raw_line.startswith("#") or raw_line.lstrip().startswith(('-', '*')):
|
|
160
|
+
attr |= curses.A_BOLD
|
|
161
|
+
if "`" in raw_line:
|
|
162
|
+
raw_line = raw_line.replace("`", "")
|
|
163
|
+
attr |= curses.A_REVERSE
|
|
164
|
+
for line in self._compose_lines(raw_line, max(12, width - len(prefix))):
|
|
165
|
+
lines.append((f"{prefix}{line}" if prefix.strip() else line, attr))
|
|
166
|
+
prefix = " "
|
|
167
|
+
has_body = True
|
|
168
|
+
return lines
|
|
169
|
+
|
|
104
170
|
def _draw_box(self, stdscr, top: int, left: int, height: int, width: int, title: str | None = None) -> None:
|
|
105
171
|
if height < 3 or width < 4:
|
|
106
172
|
return
|
|
@@ -121,12 +187,17 @@ class CobaltTUI:
|
|
|
121
187
|
|
|
122
188
|
header = " Cobalt Code | terminal agent "
|
|
123
189
|
stdscr.addstr(0, 1, header[: max(0, width - 2)], curses.A_BOLD)
|
|
190
|
+
for name, (button_x, button_y, button_width, _) in self._button_regions(width).items():
|
|
191
|
+
label = "[ New chat ]" if name == "new" else "[ Delete chat ]"
|
|
192
|
+
stdscr.addstr(button_y, button_x, label[:button_width], curses.A_REVERSE if name == "new" else curses.A_NORMAL)
|
|
124
193
|
|
|
125
194
|
sidebar_w = min(26, max(20, width // 4))
|
|
126
195
|
sidebar_x = 1
|
|
127
|
-
sidebar_y =
|
|
128
|
-
sidebar_h = max(8, height -
|
|
196
|
+
sidebar_y = 6
|
|
197
|
+
sidebar_h = max(8, height - 9)
|
|
129
198
|
self._draw_box(stdscr, sidebar_y, sidebar_x, sidebar_h, sidebar_w, " Chats ")
|
|
199
|
+
profile_line = f"profile: {self.profile_store.active}"
|
|
200
|
+
stdscr.addstr(sidebar_y + 1, sidebar_x + 2, profile_line[: max(0, sidebar_w - 4)], curses.A_DIM)
|
|
130
201
|
|
|
131
202
|
chat_files = self.chat_store.list_chats()[-8:]
|
|
132
203
|
for idx, chat_path in enumerate(chat_files):
|
|
@@ -143,36 +214,30 @@ class CobaltTUI:
|
|
|
143
214
|
|
|
144
215
|
main_x = sidebar_x + sidebar_w + 2
|
|
145
216
|
main_w = max(20, width - main_x - 2)
|
|
146
|
-
main_y =
|
|
147
|
-
main_h = max(8, height -
|
|
217
|
+
main_y = 6
|
|
218
|
+
main_h = max(8, height - 9)
|
|
148
219
|
self._draw_box(stdscr, main_y, main_x, main_h, main_w, f" {self.history.title or 'conversation'} ")
|
|
149
220
|
|
|
150
221
|
box_x, box_y, box_width, box_height, input_lines = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
|
|
151
222
|
if not self.history.messages:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
y = art_start + idx
|
|
223
|
+
art_start = main_y + 1
|
|
224
|
+
for index, line in enumerate(self.ascii_lines):
|
|
225
|
+
y = art_start + index
|
|
156
226
|
x = main_x + max(0, (main_w - len(line)) // 2)
|
|
157
227
|
if y < box_y:
|
|
158
228
|
stdscr.addstr(y, x, line[: max(0, main_w - 2)])
|
|
159
229
|
else:
|
|
160
|
-
|
|
161
|
-
for message in self.history.latest(18):
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
rows.append(f"{prefix}{line}" if prefix and line else line)
|
|
167
|
-
prefix = ""
|
|
168
|
-
|
|
169
|
-
visible = rows[-max(1, box_y - main_y - 2):]
|
|
170
|
-
for idx, line in enumerate(visible[: main_h - 4]):
|
|
230
|
+
formatted_rows: list[tuple[str, int, int]] = []
|
|
231
|
+
for message_index, message in enumerate(self.history.latest(18)):
|
|
232
|
+
formatted_rows.extend((line, attr, message_index) for line, attr in self._format_message_lines(message, main_w - 8, message_index))
|
|
233
|
+
|
|
234
|
+
visible = formatted_rows[-max(1, box_y - main_y - 2):]
|
|
235
|
+
for idx, (line, attr, _) in enumerate(visible[: main_h - 4]):
|
|
171
236
|
y = main_y + 1 + idx
|
|
172
237
|
if y >= box_y:
|
|
173
238
|
break
|
|
174
239
|
clipped = line[: max(0, main_w - 4)]
|
|
175
|
-
stdscr.addstr(y, main_x + 2, clipped)
|
|
240
|
+
stdscr.addstr(y, main_x + 2, clipped, attr)
|
|
176
241
|
|
|
177
242
|
self._draw_input_box(stdscr, box_x, box_y, box_width, box_height, input_lines, self.input_focused)
|
|
178
243
|
|
|
@@ -195,17 +260,28 @@ class CobaltTUI:
|
|
|
195
260
|
|
|
196
261
|
agent = CobaltAgent(settings=self.settings)
|
|
197
262
|
agent.history = self.history
|
|
198
|
-
|
|
263
|
+
def on_thinking(chunk: str) -> None:
|
|
264
|
+
if self.history.messages and self.history.messages[-1]["role"] == "assistant":
|
|
265
|
+
self.history.messages[-1]["thinking"] = self.history.messages[-1].get("thinking", "") + chunk
|
|
266
|
+
self.history.save()
|
|
267
|
+
self._render(stdscr, input_text, "")
|
|
268
|
+
|
|
269
|
+
response = agent.process_stream(input_text.strip(), on_chunk=on_chunk, on_thinking=on_thinking)
|
|
199
270
|
self.history = agent.history
|
|
200
271
|
self.current_chat = self.history
|
|
201
272
|
self.saved_chats = self.chat_store.list_chats()
|
|
273
|
+
if response and not buffer:
|
|
274
|
+
buffer = response
|
|
275
|
+
if not buffer:
|
|
276
|
+
raise RuntimeError("The assistant returned an empty response")
|
|
277
|
+
self._render(stdscr, "", "")
|
|
202
278
|
return buffer
|
|
203
279
|
|
|
204
280
|
def _handle_mouse_click(self, x: int, y: int) -> bool:
|
|
205
281
|
sidebar_w = min(26, max(20, curses.COLS // 4))
|
|
206
282
|
sidebar_x = 1
|
|
207
|
-
sidebar_y =
|
|
208
|
-
sidebar_h = max(8, curses.LINES -
|
|
283
|
+
sidebar_y = 6
|
|
284
|
+
sidebar_h = max(8, curses.LINES - 9)
|
|
209
285
|
|
|
210
286
|
if sidebar_x <= x < sidebar_x + sidebar_w and sidebar_y <= y < sidebar_y + sidebar_h:
|
|
211
287
|
chat_files = self.chat_store.list_chats()[-8:]
|
|
@@ -224,12 +300,33 @@ class CobaltTUI:
|
|
|
224
300
|
height, width = curses.LINES, curses.COLS
|
|
225
301
|
sidebar_w = min(26, max(20, width // 4))
|
|
226
302
|
main_x = 3 + sidebar_w
|
|
227
|
-
main_y =
|
|
303
|
+
main_y = 6
|
|
228
304
|
main_w = max(20, width - main_x - 2)
|
|
229
|
-
main_h = max(8, height -
|
|
305
|
+
main_h = max(8, height - 9)
|
|
230
306
|
box_x, box_y, box_width, box_height, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
|
|
231
307
|
return box_x <= x < box_x + box_width and box_y <= y < box_y + box_height
|
|
232
308
|
|
|
309
|
+
def _handle_thinking_click(self, x: int, y: int) -> bool:
|
|
310
|
+
height, width = curses.LINES, curses.COLS
|
|
311
|
+
sidebar_w = min(26, max(20, width // 4))
|
|
312
|
+
main_x, main_y = 3 + sidebar_w, 6
|
|
313
|
+
main_w, main_h = max(20, width - main_x - 2), max(8, height - 9)
|
|
314
|
+
_, box_y, _, _, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, "")
|
|
315
|
+
rows: list[tuple[str, int]] = []
|
|
316
|
+
for index, message in enumerate(self.history.latest(18)):
|
|
317
|
+
rows.extend(self._format_message_lines(message, main_w - 8, index))
|
|
318
|
+
for row_index, (line, _) in enumerate(rows):
|
|
319
|
+
if main_y + 1 + row_index >= box_y:
|
|
320
|
+
break
|
|
321
|
+
if line.startswith(("[-] thinking", "[+] thinking")) and main_y + 1 + row_index == y:
|
|
322
|
+
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)
|
|
323
|
+
if message_index in self.thinking_expanded:
|
|
324
|
+
self.thinking_expanded.remove(message_index)
|
|
325
|
+
else:
|
|
326
|
+
self.thinking_expanded.add(message_index)
|
|
327
|
+
return True
|
|
328
|
+
return False
|
|
329
|
+
|
|
233
330
|
def run(self) -> int:
|
|
234
331
|
def _main(stdscr):
|
|
235
332
|
curses.curs_set(0)
|
|
@@ -251,11 +348,24 @@ class CobaltTUI:
|
|
|
251
348
|
|
|
252
349
|
if ch == curses.KEY_MOUSE:
|
|
253
350
|
_, x, y, _, button_state = curses.getmouse()
|
|
351
|
+
button = self._handle_button_click(x, y, stdscr.getmaxyx()[1]) if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) else None
|
|
352
|
+
if button == "new":
|
|
353
|
+
self._start_new_chat()
|
|
354
|
+
input_text = ""
|
|
355
|
+
self.input_focused = True
|
|
356
|
+
continue
|
|
357
|
+
if button == "delete":
|
|
358
|
+
self._delete_selected_chat()
|
|
359
|
+
input_text = ""
|
|
360
|
+
self.input_focused = False
|
|
361
|
+
continue
|
|
254
362
|
if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._click_is_in_input(x, y, input_text):
|
|
255
363
|
self.input_focused = True
|
|
256
364
|
continue
|
|
365
|
+
if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._handle_thinking_click(x, y):
|
|
366
|
+
continue
|
|
257
367
|
if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._handle_mouse_click(x, y):
|
|
258
|
-
status = "
|
|
368
|
+
status = ""
|
|
259
369
|
input_text = ""
|
|
260
370
|
self.input_focused = False
|
|
261
371
|
continue
|
|
@@ -265,33 +375,6 @@ class CobaltTUI:
|
|
|
265
375
|
if ch == 27:
|
|
266
376
|
self.input_focused = False
|
|
267
377
|
continue
|
|
268
|
-
if self._should_handle_shortcut(ch, self.input_focused):
|
|
269
|
-
if ch in (14, ord("n")):
|
|
270
|
-
new_chat = self.chat_store.new_chat(title="New chat")
|
|
271
|
-
self.current_chat = new_chat
|
|
272
|
-
self.history = self.current_chat
|
|
273
|
-
self.selected_chat_path = str(self.current_chat.path)
|
|
274
|
-
self.chat_files = self.chat_store.list_chats()
|
|
275
|
-
input_text = ""
|
|
276
|
-
status = "New chat started"
|
|
277
|
-
continue
|
|
278
|
-
|
|
279
|
-
if ch in (4, ord("d")) and self.selected_chat_path:
|
|
280
|
-
target = Path(self.selected_chat_path)
|
|
281
|
-
if target.exists():
|
|
282
|
-
self.chat_store.delete_chat(target)
|
|
283
|
-
self.chat_files = self.chat_store.list_chats()
|
|
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 = "Chat deleted"
|
|
294
|
-
continue
|
|
295
378
|
if ch in (10, 13, curses.KEY_ENTER):
|
|
296
379
|
if input_text.strip():
|
|
297
380
|
try:
|
|
@@ -299,7 +382,7 @@ class CobaltTUI:
|
|
|
299
382
|
self.history.title = self._suggest_chat_title(input_text.strip())
|
|
300
383
|
self.history.save()
|
|
301
384
|
self._stream_response(stdscr, input_text)
|
|
302
|
-
status = "
|
|
385
|
+
status = ""
|
|
303
386
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
304
387
|
self.history.add("assistant", f"Error: {exc}")
|
|
305
388
|
status = ""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cobalt-cli-linux
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Agentic Groq-powered CLI assistant for Debian Linux
|
|
5
5
|
Author: Cobalt
|
|
6
6
|
License-Expression: MIT
|
|
@@ -77,7 +77,9 @@ 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.
|
|
81
83
|
|
|
82
84
|
You can also pass arguments directly:
|
|
83
85
|
|
|
@@ -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
|
|
@@ -6,6 +6,7 @@ from cobalt_cli_linux.config import Settings
|
|
|
6
6
|
from cobalt_cli_linux.executor import ShellExecutor
|
|
7
7
|
from cobalt_cli_linux.groq_client import GroqClient
|
|
8
8
|
from cobalt_cli_linux.history import ChatStore, ConversationHistory
|
|
9
|
+
from cobalt_cli_linux.profiles import ProfileStore
|
|
9
10
|
from cobalt_cli_linux.tui import CobaltTUI
|
|
10
11
|
|
|
11
12
|
|
|
@@ -46,22 +47,15 @@ def test_chat_store_delete_chat(tmp_path: Path) -> None:
|
|
|
46
47
|
assert store.list_chats()[0].name == second.path.name
|
|
47
48
|
|
|
48
49
|
|
|
49
|
-
def
|
|
50
|
+
def test_tui_buttons_replace_chat_shortcuts() -> None:
|
|
50
51
|
tui = CobaltTUI(settings=Settings())
|
|
51
52
|
|
|
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
|
|
53
|
+
buttons = tui._button_regions(120)
|
|
59
54
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
tui
|
|
63
|
-
|
|
64
|
-
assert tui._should_handle_shortcut(ord("q")) is False
|
|
55
|
+
assert buttons["new"][2] == len("[ New chat ]")
|
|
56
|
+
assert buttons["delete"][2] == len("[ Delete chat ]")
|
|
57
|
+
assert tui._handle_button_click(buttons["new"][0], buttons["new"][1], 120) == "new"
|
|
58
|
+
assert tui._handle_button_click(buttons["delete"][0], buttons["delete"][1], 120) == "delete"
|
|
65
59
|
|
|
66
60
|
|
|
67
61
|
def test_no_prompt_launches_tui(monkeypatch) -> None:
|
|
@@ -126,6 +120,26 @@ def test_stream_error_reads_response_before_inspecting_body(monkeypatch) -> None
|
|
|
126
120
|
raise AssertionError("Expected streamed API error")
|
|
127
121
|
|
|
128
122
|
|
|
123
|
+
def test_profiles_create_delete_and_guest_limit(tmp_path: Path) -> None:
|
|
124
|
+
store = ProfileStore(tmp_path / "profiles.json")
|
|
125
|
+
|
|
126
|
+
assert store.allow_prompt() is True
|
|
127
|
+
assert store.allow_prompt() is True
|
|
128
|
+
assert store.allow_prompt() is True
|
|
129
|
+
assert store.allow_prompt() is False
|
|
130
|
+
assert store.create("personal") == "personal"
|
|
131
|
+
assert store.allow_prompt() is True
|
|
132
|
+
assert store.delete("personal") is True
|
|
133
|
+
assert store.active == "guest"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_tui_empty_chat_does_not_load_prompt_art_as_body_content(tmp_path: Path) -> None:
|
|
137
|
+
tui = CobaltTUI(settings=Settings())
|
|
138
|
+
|
|
139
|
+
assert tui.ascii_lines
|
|
140
|
+
assert tui.ascii_lines[0].strip().startswith("____")
|
|
141
|
+
|
|
142
|
+
|
|
129
143
|
def test_write_file_tool_creates_and_verifies_file(tmp_path: Path) -> None:
|
|
130
144
|
agent = CobaltAgent(settings=Settings(), history=ConversationHistory(tmp_path / "history.json"))
|
|
131
145
|
target = tmp_path / "test.txt"
|
|
File without changes
|
|
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.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/requires.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/top_level.txt
RENAMED
|
File without changes
|