cobalt-cli-linux 0.1.5__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.5 → cobalt_cli_linux-0.1.7}/PKG-INFO +1 -1
  2. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/pyproject.toml +1 -1
  3. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/agent.py +3 -0
  4. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/groq_client.py +6 -2
  5. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/tui.py +132 -3
  6. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux.egg-info/PKG-INFO +1 -1
  7. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/tests/test_cli.py +71 -0
  8. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/README.md +0 -0
  9. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/setup.cfg +0 -0
  10. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/ASCII.txt +0 -0
  11. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/__init__.py +0 -0
  12. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/__main__.py +0 -0
  13. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/cli.py +0 -0
  14. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/config.py +0 -0
  15. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/deepseek_client.py +0 -0
  16. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/executor.py +0 -0
  17. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/history.py +0 -0
  18. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux/profiles.py +0 -0
  19. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux.egg-info/SOURCES.txt +0 -0
  20. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
  21. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
  22. {cobalt_cli_linux-0.1.5 → cobalt_cli_linux-0.1.7}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
  23. {cobalt_cli_linux-0.1.5 → 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.5
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.5"
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"
@@ -169,6 +169,9 @@ class CobaltAgent:
169
169
  on_chunk(chunk)
170
170
  buffer = follow_up_buffer
171
171
 
172
+ if not buffer.strip():
173
+ buffer = "I could not produce a visible response for that request. Please try again."
174
+
172
175
  if self.history.messages and self.history.messages[-1].get("role") == "assistant":
173
176
  self.history.messages[-1]["content"] = buffer
174
177
  if thinking_buffer:
@@ -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 = {
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import curses
4
4
  import re
5
+ import shutil
6
+ import subprocess
5
7
  import textwrap
6
8
  from pathlib import Path
7
9
 
@@ -30,6 +32,7 @@ class CobaltTUI:
30
32
  self.auth_view: str | None = None
31
33
  self.auth_field = 0
32
34
  self.auth_values = ["", ""]
35
+ self.scroll_offset = 0
33
36
 
34
37
  def _load_ascii_art(self) -> list[str]:
35
38
  ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
@@ -91,6 +94,7 @@ class CobaltTUI:
91
94
  self.history = self.current_chat
92
95
  self.selected_chat_path = str(self.current_chat.path)
93
96
  self.chat_files = self.chat_store.list_chats()
97
+ self.scroll_offset = 0
94
98
 
95
99
  def _delete_selected_chat(self) -> None:
96
100
  if not self.selected_chat_path:
@@ -104,6 +108,7 @@ class CobaltTUI:
104
108
  self.selected_chat_path = str(self.chat_files[-1])
105
109
  self.history = self.chat_store.load_chat(self.selected_chat_path)
106
110
  self.current_chat = self.history
111
+ self.scroll_offset = 0
107
112
  else:
108
113
  self._start_new_chat()
109
114
 
@@ -141,7 +146,7 @@ class CobaltTUI:
141
146
 
142
147
  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]]:
143
148
  box_width = max(20, main_w - 4)
144
- text_width = max(10, box_width - 6)
149
+ text_width = max(10, box_width - 6 - len("[ Copy prompt ]") - 2)
145
150
  input_lines = textwrap.wrap(
146
151
  input_text,
147
152
  width=text_width,
@@ -167,8 +172,56 @@ class CobaltTUI:
167
172
  for index, line in enumerate(input_lines):
168
173
  prompt = "> " if index == 0 else " "
169
174
  stdscr.addstr(box_y + 1 + index, box_x, f"{prompt}{line}"[:box_width])
175
+ copy_label = "[ Copy prompt ]"
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)
180
+ stdscr.addstr(box_y + 1, copy_x, copy_label[: max(0, box_width - (copy_x - box_x))], curses.A_DIM)
170
181
  stdscr.addstr(box_y + box_height - 1, box_x, bottom[:box_width], curses.A_BOLD if focused else curses.A_DIM)
171
182
 
183
+ def _copy_to_clipboard(self, text: str) -> str:
184
+ if not text:
185
+ raise ValueError("Nothing to copy")
186
+ for command in ("wl-copy", "xclip", "xsel"):
187
+ executable = shutil.which(command)
188
+ if not executable:
189
+ continue
190
+ args = [executable]
191
+ if command == "xclip":
192
+ args.extend(["-selection", "clipboard"])
193
+ elif command == "xsel":
194
+ args.extend(["--clipboard", "--input"])
195
+ try:
196
+ subprocess.run(args, input=text, text=True, check=True)
197
+ except (OSError, subprocess.CalledProcessError):
198
+ continue
199
+ return "Copied"
200
+ raise RuntimeError("No clipboard utility found (install wl-clipboard, xclip, or xsel)")
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
+
219
+ def _latest_response(self) -> str:
220
+ for message in reversed(self.history.messages):
221
+ if message.get("role") == "assistant":
222
+ return message.get("content", "")
223
+ return ""
224
+
172
225
  def _compose_lines(self, text: str, width: int) -> list[str]:
173
226
  lines: list[str] = []
174
227
  for paragraph in text.splitlines() or [""]:
@@ -278,7 +331,9 @@ class CobaltTUI:
278
331
  for message_index, message in enumerate(self.history.latest(18)):
279
332
  formatted_rows.extend((line, attr, message_index) for line, attr in self._format_message_lines(message, main_w - 8, message_index))
280
333
 
281
- 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]
282
337
  for idx, (line, attr, _) in enumerate(visible[: main_h - 4]):
283
338
  y = main_y + 1 + idx
284
339
  if y >= box_y:
@@ -286,6 +341,10 @@ class CobaltTUI:
286
341
  clipped = line[: max(0, main_w - 4)]
287
342
  stdscr.addstr(y, main_x + 2, clipped, attr)
288
343
 
344
+ copy_label = "[ Copy response ]"
345
+ copy_y = min(box_y - 1, main_y + main_h - box_height - 2)
346
+ stdscr.addstr(copy_y, main_x + 2, copy_label, curses.A_DIM)
347
+
289
348
  self._draw_input_box(stdscr, box_x, box_y, box_width, box_height, input_lines, self.input_focused)
290
349
 
291
350
  if status:
@@ -340,6 +399,7 @@ class CobaltTUI:
340
399
  self.selected_chat_path = str(target)
341
400
  self.history = self.chat_store.load_chat(target)
342
401
  self.current_chat = self.history
402
+ self.scroll_offset = 0
343
403
  return True
344
404
  return False
345
405
 
@@ -353,6 +413,34 @@ class CobaltTUI:
353
413
  box_x, box_y, box_width, box_height, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
354
414
  return box_x <= x < box_x + box_width and box_y <= y < box_y + box_height
355
415
 
416
+ def _copy_button_at(self, x: int, y: int, input_text: str) -> str | None:
417
+ height, width = curses.LINES, curses.COLS
418
+ sidebar_w = min(26, max(20, width // 4))
419
+ main_x, main_y = 3 + sidebar_w, 6
420
+ main_w, main_h = max(20, width - main_x - 2), max(8, height - 9)
421
+ _, box_y, box_width, box_height, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
422
+ prompt_copy_x = main_x + 2 + box_width - len("[ Copy prompt ]") - 2
423
+ if prompt_copy_x <= x < prompt_copy_x + len("[ Copy prompt ]") and box_y + 1 <= y <= box_y + 1:
424
+ return "prompt"
425
+ if self.history.messages:
426
+ copy_y = min(box_y - 1, main_y + main_h - box_height - 2)
427
+ if main_x + 2 <= x < main_x + 2 + len("[ Copy response ]") and y == copy_y:
428
+ return "response"
429
+ return None
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
+
356
444
  def _handle_thinking_click(self, x: int, y: int) -> bool:
357
445
  height, width = curses.LINES, curses.COLS
358
446
  sidebar_w = min(26, max(20, width // 4))
@@ -394,7 +482,30 @@ class CobaltTUI:
394
482
  ch = stdscr.getch()
395
483
 
396
484
  if ch == curses.KEY_MOUSE:
397
- _, 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
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
496
+ if copy_target:
497
+ try:
498
+ self._copy_to_clipboard(self._latest_response() if copy_target == "response" else input_text)
499
+ except (OSError, RuntimeError, ValueError) as exc:
500
+ self.history.add("assistant", f"Error: {exc}")
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
398
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
399
510
  if button in ("signin", "signup"):
400
511
  self._open_auth(button)
@@ -429,6 +540,24 @@ class CobaltTUI:
429
540
  continue
430
541
  self.input_focused = False
431
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
432
561
  if self.auth_view:
433
562
  if ch in (10, 13, curses.KEY_ENTER):
434
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.5
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
@@ -60,6 +61,45 @@ def test_tui_buttons_replace_chat_shortcuts() -> None:
60
61
  assert tui._handle_button_click(buttons["delete"][0], buttons["delete"][1], 120) == "delete"
61
62
 
62
63
 
64
+ def test_tui_copy_uses_available_clipboard_command(monkeypatch) -> None:
65
+ tui = CobaltTUI(settings=Settings())
66
+ tui.history.add("assistant", "response text")
67
+ captured = {}
68
+
69
+ monkeypatch.setattr("cobalt_cli_linux.tui.shutil.which", lambda command: "/usr/bin/wl-copy" if command == "wl-copy" else None)
70
+ monkeypatch.setattr("cobalt_cli_linux.tui.subprocess.run", lambda args, **kwargs: captured.update(args=args, kwargs=kwargs))
71
+
72
+ assert tui._latest_response() == "response text"
73
+ assert tui._copy_to_clipboard("prompt text") == "Copied"
74
+ assert captured["kwargs"]["input"] == "prompt text"
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
+
91
+ def test_streaming_empty_answer_gets_visible_fallback(monkeypatch, tmp_path: Path) -> None:
92
+ agent = CobaltAgent(settings=Settings(), history=ConversationHistory(tmp_path / "history.json"))
93
+ agent.profile_store.data["active"] = "personal"
94
+
95
+ monkeypatch.setattr(agent.client, "chat_completion_stream_events", lambda *args, **kwargs: iter(()))
96
+
97
+ response = agent.process_stream("hello")
98
+
99
+ assert response.startswith("I could not produce a visible response")
100
+ assert agent.history.messages[-1]["content"] == response
101
+
102
+
63
103
  def test_no_prompt_launches_tui(monkeypatch) -> None:
64
104
  launched = []
65
105
 
@@ -222,3 +262,34 @@ def test_groq_client_auto_detects_available_model(monkeypatch) -> None:
222
262
  client = GroqClient("test-key", model="auto")
223
263
  assert client.resolve_model() == "llama-3.3-70b-versatile"
224
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