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.
Files changed (24) hide show
  1. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/PKG-INFO +6 -2
  2. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/README.md +5 -1
  3. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/pyproject.toml +1 -1
  4. cobalt_cli_linux-0.1.5/src/cobalt_cli_linux/ASCII.txt +5 -0
  5. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/agent.py +15 -2
  6. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/groq_client.py +19 -2
  7. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/history.py +5 -1
  8. cobalt_cli_linux-0.1.5/src/cobalt_cli_linux/profiles.py +90 -0
  9. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/tui.py +204 -54
  10. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/PKG-INFO +6 -2
  11. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/SOURCES.txt +1 -0
  12. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/tests/test_cli.py +33 -14
  13. cobalt_cli_linux-0.1.3/src/cobalt_cli_linux/ASCII.txt +0 -5
  14. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/setup.cfg +0 -0
  15. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/__init__.py +0 -0
  16. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/__main__.py +0 -0
  17. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/cli.py +0 -0
  18. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/config.py +0 -0
  19. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/deepseek_client.py +0 -0
  20. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux/executor.py +0 -0
  21. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
  22. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
  23. {cobalt_cli_linux-0.1.3 → cobalt_cli_linux-0.1.5}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
  24. {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
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, press `Escape` to unfocus the prompt. While unfocused, `n` starts a new chat and `d` deletes the selected chat; click the prompt or type a character to focus it again. Long prompts wrap inside the prompt box.
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, press `Escape` to unfocus the prompt. While unfocused, `n` starts a new chat and `d` deletes the selected chat; click the prompt or type a character to focus it again. Long prompts wrap inside the prompt box.
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cobalt-cli-linux"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "Agentic Groq-powered CLI assistant for Debian Linux"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,5 @@
1
+ ____ _ _ _ ____ _
2
+ / ___|___ | |__ __ _| | |_ / ___|___ __| | ___
3
+ | | / _ \| '_ \ / _` | | __| | | / _ \ / _` |/ _ \
4
+ | |__| (_) | |_) | (_| | | |_ | |__| (_) | (_| | __/
5
+ \____\___/|_.__/ \__,_|_|\__| \____\___/ \__,_|\___|
@@ -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
- for chunk in self.client.chat_completion_stream(messages, temperature=self.settings.temperature, max_tokens=self.settings.max_tokens):
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
- {"role": str(item.get("role", "user")), "content": str(item.get("content", ""))}
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 _should_handle_shortcut(self, ch: int, input_focused: bool = False) -> bool:
62
- return not input_focused and ch in (14, 4, ord("n"), ord("d"))
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
- rows: list[str] = []
156
- for message in self.history.latest(18):
157
- role = message.get("role", "user").upper()
158
- content = message.get("content", "")
159
- prefix = f"{role.lower()} "
160
- for line in self._compose_lines(content, max(12, main_w - 8 - len(prefix))):
161
- rows.append(f"{prefix}{line}" if prefix and line else line)
162
- prefix = ""
163
-
164
- visible = rows[-max(1, box_y - main_y - 2):]
165
- for idx, line in enumerate(visible[: main_h - 4]):
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
- response = agent.process_stream(input_text.strip(), on_chunk=on_chunk)
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._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 = ""
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
- 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
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
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, press `Escape` to unfocus the prompt. While unfocused, `n` starts a new chat and `d` deletes the selected chat; click the prompt or type a character to focus it again. Long prompts wrap inside the prompt box.
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 test_tui_shortcuts_ignore_typed_letters() -> None:
52
+ def test_tui_buttons_replace_chat_shortcuts() -> None:
50
53
  tui = CobaltTUI(settings=Settings())
51
54
 
52
- assert tui._should_handle_shortcut(14) is True
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
- def test_q_is_not_a_shortcut_when_typing() -> None:
62
- tui = CobaltTUI(settings=Settings())
63
-
64
- assert tui._should_handle_shortcut(ord("q")) is False
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 not hasattr(tui, "ascii_lines")
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:
@@ -1,5 +0,0 @@
1
- ____ ___ ____ _ _ _____ ____ _ ___
2
- / ___/ _ \| __ ) / \ | | |_ _| / ___| | |_ _|
3
- | | | | | | _ \ / _ \ | | | | | | | | | |
4
- | |__| |_| | |_) / ___ \| |___| | | |___| |___ | |
5
- \____\___/|____/_/ \_\_____|_| \____|_____|___|