cobalt-cli-linux 0.1.6__tar.gz → 0.1.7__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 (23) hide show
  1. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/PKG-INFO +1 -1
  2. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/pyproject.toml +1 -1
  3. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/groq_client.py +6 -2
  4. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/tui.py +79 -3
  5. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux.egg-info/PKG-INFO +1 -1
  6. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/tests/test_cli.py +46 -0
  7. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/README.md +0 -0
  8. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/setup.cfg +0 -0
  9. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/ASCII.txt +0 -0
  10. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/__init__.py +0 -0
  11. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/__main__.py +0 -0
  12. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/agent.py +0 -0
  13. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/cli.py +0 -0
  14. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/config.py +0 -0
  15. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/deepseek_client.py +0 -0
  16. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/executor.py +0 -0
  17. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/history.py +0 -0
  18. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/profiles.py +0 -0
  19. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux.egg-info/SOURCES.txt +0 -0
  20. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
  21. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
  22. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
  23. {cobalt_cli_linux-0.1.6 → cobalt_cli_linux-0.1.7}/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.6
3
+ Version: 0.1.7
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cobalt-cli-linux"
7
- version = "0.1.6"
7
+ version = "0.1.7"
8
8
  description = "Agentic Groq-powered CLI assistant for Debian Linux"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -16,6 +16,7 @@ class GroqStreamEvent(dict[str, str]):
16
16
 
17
17
 
18
18
  class GroqClient:
19
+ MAX_MODEL_TOKENS = 512
19
20
  DEFAULT_MODELS: ClassVar[list[str]] = [
20
21
  "llama-3.3-70b-versatile",
21
22
  "llama-3.1-8b-instant",
@@ -46,6 +47,9 @@ class GroqClient:
46
47
  lowered = model_id.lower()
47
48
  return not any(token in lowered for token in self.NON_CHAT_TOKENS)
48
49
 
50
+ def _safe_max_tokens(self, max_tokens: int) -> int:
51
+ return max(1, min(max_tokens, self.MAX_MODEL_TOKENS))
52
+
49
53
  def resolve_model(self) -> str:
50
54
  if self.model and self.model != "auto":
51
55
  return self.model
@@ -85,7 +89,7 @@ class GroqClient:
85
89
  "model": resolved_model,
86
90
  "messages": messages,
87
91
  "temperature": temperature,
88
- "max_tokens": max_tokens,
92
+ "max_tokens": self._safe_max_tokens(max_tokens),
89
93
  }
90
94
  headers = {
91
95
  "Authorization": f"Bearer {self.api_key}",
@@ -135,7 +139,7 @@ class GroqClient:
135
139
  "model": resolved_model,
136
140
  "messages": messages,
137
141
  "temperature": temperature,
138
- "max_tokens": max_tokens,
142
+ "max_tokens": self._safe_max_tokens(max_tokens),
139
143
  "stream": True,
140
144
  }
141
145
  headers = {
@@ -32,6 +32,7 @@ class CobaltTUI:
32
32
  self.auth_view: str | None = None
33
33
  self.auth_field = 0
34
34
  self.auth_values = ["", ""]
35
+ self.scroll_offset = 0
35
36
 
36
37
  def _load_ascii_art(self) -> list[str]:
37
38
  ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
@@ -93,6 +94,7 @@ class CobaltTUI:
93
94
  self.history = self.current_chat
94
95
  self.selected_chat_path = str(self.current_chat.path)
95
96
  self.chat_files = self.chat_store.list_chats()
97
+ self.scroll_offset = 0
96
98
 
97
99
  def _delete_selected_chat(self) -> None:
98
100
  if not self.selected_chat_path:
@@ -106,6 +108,7 @@ class CobaltTUI:
106
108
  self.selected_chat_path = str(self.chat_files[-1])
107
109
  self.history = self.chat_store.load_chat(self.selected_chat_path)
108
110
  self.current_chat = self.history
111
+ self.scroll_offset = 0
109
112
  else:
110
113
  self._start_new_chat()
111
114
 
@@ -171,6 +174,9 @@ class CobaltTUI:
171
174
  stdscr.addstr(box_y + 1 + index, box_x, f"{prompt}{line}"[:box_width])
172
175
  copy_label = "[ Copy prompt ]"
173
176
  copy_x = max(box_x + 4, box_x + box_width - len(copy_label) - 2)
177
+ paste_label = "[ Paste ]"
178
+ paste_x = max(box_x + 4, copy_x - len(paste_label) - 2)
179
+ stdscr.addstr(box_y + 1, paste_x, paste_label[: max(0, copy_x - paste_x - 2)], curses.A_DIM)
174
180
  stdscr.addstr(box_y + 1, copy_x, copy_label[: max(0, box_width - (copy_x - box_x))], curses.A_DIM)
175
181
  stdscr.addstr(box_y + box_height - 1, box_x, bottom[:box_width], curses.A_BOLD if focused else curses.A_DIM)
176
182
 
@@ -186,10 +192,30 @@ class CobaltTUI:
186
192
  args.extend(["-selection", "clipboard"])
187
193
  elif command == "xsel":
188
194
  args.extend(["--clipboard", "--input"])
189
- subprocess.run(args, input=text, text=True, check=True)
195
+ try:
196
+ subprocess.run(args, input=text, text=True, check=True)
197
+ except (OSError, subprocess.CalledProcessError):
198
+ continue
190
199
  return "Copied"
191
200
  raise RuntimeError("No clipboard utility found (install wl-clipboard, xclip, or xsel)")
192
201
 
202
+ def _paste_from_clipboard(self) -> str:
203
+ for command in ("wl-paste", "xclip", "xsel"):
204
+ executable = shutil.which(command)
205
+ if not executable:
206
+ continue
207
+ args = [executable]
208
+ if command == "xclip":
209
+ args.extend(["-selection", "clipboard", "-o"])
210
+ elif command == "xsel":
211
+ args.extend(["--clipboard", "--output"])
212
+ try:
213
+ result = subprocess.run(args, capture_output=True, text=True, check=True)
214
+ except (OSError, subprocess.CalledProcessError):
215
+ continue
216
+ return result.stdout.replace("\r\n", "\n").replace("\r", "\n")
217
+ raise RuntimeError("No working clipboard utility found (install wl-clipboard, xclip, or xsel)")
218
+
193
219
  def _latest_response(self) -> str:
194
220
  for message in reversed(self.history.messages):
195
221
  if message.get("role") == "assistant":
@@ -305,7 +331,9 @@ class CobaltTUI:
305
331
  for message_index, message in enumerate(self.history.latest(18)):
306
332
  formatted_rows.extend((line, attr, message_index) for line, attr in self._format_message_lines(message, main_w - 8, message_index))
307
333
 
308
- visible = formatted_rows[-max(1, box_y - main_y - 2):]
334
+ content_height = max(1, box_y - main_y - 2)
335
+ end = max(0, len(formatted_rows) - self.scroll_offset)
336
+ visible = formatted_rows[max(0, end - content_height):end]
309
337
  for idx, (line, attr, _) in enumerate(visible[: main_h - 4]):
310
338
  y = main_y + 1 + idx
311
339
  if y >= box_y:
@@ -371,6 +399,7 @@ class CobaltTUI:
371
399
  self.selected_chat_path = str(target)
372
400
  self.history = self.chat_store.load_chat(target)
373
401
  self.current_chat = self.history
402
+ self.scroll_offset = 0
374
403
  return True
375
404
  return False
376
405
 
@@ -399,6 +428,19 @@ class CobaltTUI:
399
428
  return "response"
400
429
  return None
401
430
 
431
+ def _paste_button_at(self, x: int, y: int, input_text: str) -> bool:
432
+ height, width = curses.LINES, curses.COLS
433
+ sidebar_w = min(26, max(20, width // 4))
434
+ main_x, main_y = 3 + sidebar_w, 6
435
+ main_w, main_h = max(20, width - main_x - 2), max(8, height - 9)
436
+ _, box_y, box_width, _, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
437
+ label = "[ Paste ]"
438
+ paste_x = main_x + 2 + box_width - len("[ Copy prompt ]") - len(label) - 4
439
+ return paste_x <= x < paste_x + len(label) and y == box_y + 1
440
+
441
+ def _paste_into_input(self, input_text: str) -> str:
442
+ return input_text + self._paste_from_clipboard()
443
+
402
444
  def _handle_thinking_click(self, x: int, y: int) -> bool:
403
445
  height, width = curses.LINES, curses.COLS
404
446
  sidebar_w = min(26, max(20, width // 4))
@@ -440,7 +482,16 @@ class CobaltTUI:
440
482
  ch = stdscr.getch()
441
483
 
442
484
  if ch == curses.KEY_MOUSE:
443
- _, x, y, _, button_state = curses.getmouse()
485
+ try:
486
+ _, x, y, _, button_state = curses.getmouse()
487
+ except curses.error:
488
+ continue
489
+ if button_state & getattr(curses, "BUTTON4_PRESSED", 0):
490
+ self.scroll_offset = min(self.scroll_offset + 3, max(0, len(self.history.messages) * 3))
491
+ continue
492
+ if button_state & getattr(curses, "BUTTON5_PRESSED", 0):
493
+ self.scroll_offset = max(0, self.scroll_offset - 3)
494
+ continue
444
495
  copy_target = self._copy_button_at(x, y, input_text) if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) else None
445
496
  if copy_target:
446
497
  try:
@@ -448,6 +499,13 @@ class CobaltTUI:
448
499
  except (OSError, RuntimeError, ValueError) as exc:
449
500
  self.history.add("assistant", f"Error: {exc}")
450
501
  continue
502
+ if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._paste_button_at(x, y, input_text):
503
+ try:
504
+ input_text += self._paste_from_clipboard()
505
+ self.input_focused = True
506
+ except (OSError, RuntimeError, ValueError) as exc:
507
+ self.history.add("assistant", f"Error: {exc}")
508
+ continue
451
509
  button = self._handle_button_click(x, y, stdscr.getmaxyx()[1]) if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) else None
452
510
  if button in ("signin", "signup"):
453
511
  self._open_auth(button)
@@ -482,6 +540,24 @@ class CobaltTUI:
482
540
  continue
483
541
  self.input_focused = False
484
542
  continue
543
+ if ch == 22 and self.input_focused:
544
+ try:
545
+ input_text = self._paste_into_input(input_text)
546
+ except (OSError, RuntimeError, ValueError) as exc:
547
+ self.history.add("assistant", f"Error: {exc}")
548
+ continue
549
+ if ch == curses.KEY_UP:
550
+ self.scroll_offset = min(self.scroll_offset + 1, max(0, len(self.history.messages) * 3))
551
+ continue
552
+ if ch == curses.KEY_DOWN:
553
+ self.scroll_offset = max(0, self.scroll_offset - 1)
554
+ continue
555
+ if ch == curses.KEY_PPAGE:
556
+ self.scroll_offset = min(self.scroll_offset + 8, max(0, len(self.history.messages) * 3))
557
+ continue
558
+ if ch == curses.KEY_NPAGE:
559
+ self.scroll_offset = max(0, self.scroll_offset - 8)
560
+ continue
485
561
  if self.auth_view:
486
562
  if ch in (10, 13, curses.KEY_ENTER):
487
563
  if self.auth_field == 0:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cobalt-cli-linux
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -1,3 +1,4 @@
1
+ import subprocess
1
2
  from pathlib import Path
2
3
 
3
4
  import pytest
@@ -73,6 +74,20 @@ def test_tui_copy_uses_available_clipboard_command(monkeypatch) -> None:
73
74
  assert captured["kwargs"]["input"] == "prompt text"
74
75
 
75
76
 
77
+ def test_tui_copy_falls_back_when_xsel_fails(monkeypatch) -> None:
78
+ tui = CobaltTUI(settings=Settings())
79
+
80
+ monkeypatch.setattr("cobalt_cli_linux.tui.shutil.which", lambda command: f"/usr/bin/{command}")
81
+
82
+ def run(args, **kwargs):
83
+ if not args[0].endswith("xsel"):
84
+ raise subprocess.CalledProcessError(1, args)
85
+
86
+ monkeypatch.setattr("cobalt_cli_linux.tui.subprocess.run", run)
87
+
88
+ assert tui._copy_to_clipboard("text") == "Copied"
89
+
90
+
76
91
  def test_streaming_empty_answer_gets_visible_fallback(monkeypatch, tmp_path: Path) -> None:
77
92
  agent = CobaltAgent(settings=Settings(), history=ConversationHistory(tmp_path / "history.json"))
78
93
  agent.profile_store.data["active"] = "personal"
@@ -247,3 +262,34 @@ def test_groq_client_auto_detects_available_model(monkeypatch) -> None:
247
262
  client = GroqClient("test-key", model="auto")
248
263
  assert client.resolve_model() == "llama-3.3-70b-versatile"
249
264
  assert client.chat_completion([{"role": "user", "content": "hello"}]) == "ok"
265
+
266
+
267
+ def test_groq_client_clamps_max_tokens_to_model_limit(monkeypatch) -> None:
268
+ captured = {}
269
+
270
+ class FakeResponse:
271
+ status_code = 200
272
+
273
+ def json(self):
274
+ return {"choices": [{"message": {"content": "ok"}}]}
275
+
276
+ class FakeClient:
277
+ def __init__(self, timeout=None):
278
+ pass
279
+
280
+ def __enter__(self):
281
+ return self
282
+
283
+ def __exit__(self, exc_type, exc, tb):
284
+ return False
285
+
286
+ def post(self, url, headers=None, json=None):
287
+ captured.update(json)
288
+ return FakeResponse()
289
+
290
+ monkeypatch.setattr("cobalt_cli_linux.groq_client.httpx.Client", FakeClient)
291
+ client = GroqClient("test-key", model="llama-3.1-8b-instant")
292
+
293
+ client.chat_completion([{"role": "user", "content": "hello"}], max_tokens=1024)
294
+
295
+ assert captured["max_tokens"] == 512