cobalt-cli-linux 0.1.3__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.3 → cobalt_cli_linux-0.1.4}/PKG-INFO +4 -2
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/README.md +3 -1
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/pyproject.toml +1 -1
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/agent.py +15 -2
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/groq_client.py +19 -2
- {cobalt_cli_linux-0.1.3 → 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.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/tui.py +138 -55
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/PKG-INFO +4 -2
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/SOURCES.txt +1 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/tests/test_cli.py +22 -14
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/setup.cfg +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/ASCII.txt +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/__init__.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/__main__.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/cli.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/config.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/deepseek_client.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/executor.py +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
- {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
- {cobalt_cli_linux-0.1.3 → 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,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,15 @@ 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
|
+
|
|
31
|
+
def _load_ascii_art(self) -> list[str]:
|
|
32
|
+
ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
|
|
33
|
+
if not ascii_path.exists():
|
|
34
|
+
return ["Cobalt CLI"]
|
|
35
|
+
return [line.rstrip() for line in ascii_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
34
36
|
|
|
35
37
|
def _refresh_chat_files(self) -> None:
|
|
36
38
|
self.chat_files = self.chat_store.list_chats()
|
|
@@ -58,8 +60,43 @@ class CobaltTUI:
|
|
|
58
60
|
title = " ".join(words[:4]).strip()
|
|
59
61
|
return title.title() if title else "New chat"
|
|
60
62
|
|
|
61
|
-
def
|
|
62
|
-
|
|
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()
|
|
63
100
|
|
|
64
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]]:
|
|
65
102
|
box_width = max(20, main_w - 4)
|
|
@@ -100,6 +137,36 @@ class CobaltTUI:
|
|
|
100
137
|
lines.extend(textwrap.wrap(paragraph, width=max(15, width), break_long_words=False, break_on_hyphens=False))
|
|
101
138
|
return lines
|
|
102
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
|
+
|
|
103
170
|
def _draw_box(self, stdscr, top: int, left: int, height: int, width: int, title: str | None = None) -> None:
|
|
104
171
|
if height < 3 or width < 4:
|
|
105
172
|
return
|
|
@@ -120,16 +187,17 @@ class CobaltTUI:
|
|
|
120
187
|
|
|
121
188
|
header = " Cobalt Code | terminal agent "
|
|
122
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)
|
|
123
193
|
|
|
124
194
|
sidebar_w = min(26, max(20, width // 4))
|
|
125
195
|
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
196
|
sidebar_y = 6
|
|
131
197
|
sidebar_h = max(8, height - 9)
|
|
132
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)
|
|
133
201
|
|
|
134
202
|
chat_files = self.chat_store.list_chats()[-8:]
|
|
135
203
|
for idx, chat_path in enumerate(chat_files):
|
|
@@ -151,23 +219,25 @@ class CobaltTUI:
|
|
|
151
219
|
self._draw_box(stdscr, main_y, main_x, main_h, main_w, f" {self.history.title or 'conversation'} ")
|
|
152
220
|
|
|
153
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)
|
|
154
|
-
if self.history.messages:
|
|
155
|
-
|
|
156
|
-
for
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
222
|
+
if not self.history.messages:
|
|
223
|
+
art_start = main_y + 1
|
|
224
|
+
for index, line in enumerate(self.ascii_lines):
|
|
225
|
+
y = art_start + index
|
|
226
|
+
x = main_x + max(0, (main_w - len(line)) // 2)
|
|
227
|
+
if y < box_y:
|
|
228
|
+
stdscr.addstr(y, x, line[: max(0, main_w - 2)])
|
|
229
|
+
else:
|
|
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]):
|
|
166
236
|
y = main_y + 1 + idx
|
|
167
237
|
if y >= box_y:
|
|
168
238
|
break
|
|
169
239
|
clipped = line[: max(0, main_w - 4)]
|
|
170
|
-
stdscr.addstr(y, main_x + 2, clipped)
|
|
240
|
+
stdscr.addstr(y, main_x + 2, clipped, attr)
|
|
171
241
|
|
|
172
242
|
self._draw_input_box(stdscr, box_x, box_y, box_width, box_height, input_lines, self.input_focused)
|
|
173
243
|
|
|
@@ -190,7 +260,13 @@ class CobaltTUI:
|
|
|
190
260
|
|
|
191
261
|
agent = CobaltAgent(settings=self.settings)
|
|
192
262
|
agent.history = self.history
|
|
193
|
-
|
|
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)
|
|
194
270
|
self.history = agent.history
|
|
195
271
|
self.current_chat = self.history
|
|
196
272
|
self.saved_chats = self.chat_store.list_chats()
|
|
@@ -230,6 +306,27 @@ class CobaltTUI:
|
|
|
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,9 +348,22 @@ 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
368
|
status = ""
|
|
259
369
|
input_text = ""
|
|
@@ -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 = ""
|
|
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 = ""
|
|
294
|
-
continue
|
|
295
378
|
if ch in (10, 13, curses.KEY_ENTER):
|
|
296
379
|
if input_text.strip():
|
|
297
380
|
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.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,10 +120,24 @@ 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
|
+
|
|
129
136
|
def test_tui_empty_chat_does_not_load_prompt_art_as_body_content(tmp_path: Path) -> None:
|
|
130
137
|
tui = CobaltTUI(settings=Settings())
|
|
131
138
|
|
|
132
|
-
assert
|
|
139
|
+
assert tui.ascii_lines
|
|
140
|
+
assert tui.ascii_lines[0].strip().startswith("____")
|
|
133
141
|
|
|
134
142
|
|
|
135
143
|
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
|
|
File without changes
|
{cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/requires.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/top_level.txt
RENAMED
|
File without changes
|