cobalt-cli-linux 0.1.1__tar.gz → 0.1.3__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 (22) hide show
  1. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/PKG-INFO +3 -1
  2. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/README.md +2 -0
  3. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/pyproject.toml +1 -1
  4. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/tui.py +89 -42
  5. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux.egg-info/PKG-INFO +3 -1
  6. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/tests/test_cli.py +10 -2
  7. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/setup.cfg +0 -0
  8. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/ASCII.txt +0 -0
  9. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/__init__.py +0 -0
  10. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/__main__.py +0 -0
  11. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/agent.py +0 -0
  12. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/cli.py +0 -0
  13. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/config.py +0 -0
  14. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/deepseek_client.py +0 -0
  15. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/executor.py +0 -0
  16. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/groq_client.py +0 -0
  17. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux/history.py +0 -0
  18. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux.egg-info/SOURCES.txt +0 -0
  19. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
  20. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
  21. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
  22. {cobalt_cli_linux-0.1.1 → cobalt_cli_linux-0.1.3}/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.1
3
+ Version: 0.1.3
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -77,6 +77,8 @@ 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.
81
+
80
82
  You can also pass arguments directly:
81
83
 
82
84
  ```bash
@@ -52,6 +52,8 @@ 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.
56
+
55
57
  You can also pass arguments directly:
56
58
 
57
59
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cobalt-cli-linux"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Agentic Groq-powered CLI assistant for Debian Linux"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -8,6 +8,14 @@ from pathlib import Path
8
8
  from .agent import CobaltAgent
9
9
  from .history import ChatStore
10
10
 
11
+ COBALT_CLI_ART = (
12
+ " ____ ___ ____ _ _ _____ ____ _ ___ ",
13
+ " / ___/ _ \\| __ ) / \\ | | |_ _| / ___| | |_ _|",
14
+ " | | | | | | _ \\ / _ \\ | | | | | | | | | | ",
15
+ " | |__| |_| | |_) / ___ \\| |___| | | |___| |___ | | ",
16
+ " \\____\\___/|____/_/ \\_\\_____|_| \\____|_____|___|",
17
+ )
18
+
11
19
 
12
20
  class CobaltTUI:
13
21
  def __init__(self, settings) -> None:
@@ -22,15 +30,7 @@ class CobaltTUI:
22
30
  self.current_chat = self.chat_store.load_chat(self.selected_chat_path)
23
31
  self.history = self.current_chat
24
32
  self.saved_chats = self.chat_files
25
- self.ascii_lines = self._load_ascii_art()
26
-
27
- def _load_ascii_art(self) -> list[str]:
28
- ascii_path = Path(__file__).with_name("ASCII.txt")
29
- if not ascii_path.exists():
30
- ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
31
- if not ascii_path.exists():
32
- return ["COBALT"]
33
- return [line.rstrip() for line in ascii_path.read_text(encoding="utf-8").splitlines() if line.strip()]
33
+ self.input_focused = True
34
34
 
35
35
  def _refresh_chat_files(self) -> None:
36
36
  self.chat_files = self.chat_store.list_chats()
@@ -58,8 +58,38 @@ class CobaltTUI:
58
58
  title = " ".join(words[:4]).strip()
59
59
  return title.title() if title else "New chat"
60
60
 
61
- def _should_handle_shortcut(self, ch: int) -> bool:
62
- return ch in (14, 4)
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"))
63
+
64
+ 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
+ box_width = max(20, main_w - 4)
66
+ text_width = max(10, box_width - 6)
67
+ input_lines = textwrap.wrap(
68
+ input_text,
69
+ width=text_width,
70
+ replace_whitespace=False,
71
+ drop_whitespace=False,
72
+ break_long_words=True,
73
+ break_on_hyphens=False,
74
+ ) or [""]
75
+ max_lines = max(1, min(6, main_h - 6))
76
+ input_lines = input_lines[-max_lines:]
77
+ box_height = len(input_lines) + 3
78
+ box_x = main_x + 2
79
+ box_y = main_y + main_h - box_height - 1
80
+ return box_x, box_y, box_width, box_height, input_lines
81
+
82
+ def _draw_input_box(self, stdscr, box_x: int, box_y: int, box_width: int, box_height: int, input_lines: list[str], focused: bool) -> None:
83
+ rule = "-" * max(0, box_width - 2)
84
+ top_label = " Ask AI "
85
+ top = f"-{top_label}{rule}"
86
+ bottom_label = " [Enter to submit] "
87
+ bottom = f"-{'-' * max(0, box_width - len(bottom_label) - 2)}{bottom_label}-"
88
+ stdscr.addstr(box_y, box_x, top[:box_width])
89
+ for index, line in enumerate(input_lines):
90
+ prompt = "> " if index == 0 else " "
91
+ stdscr.addstr(box_y + 1 + index, box_x, f"{prompt}{line}"[:box_width])
92
+ stdscr.addstr(box_y + box_height - 1, box_x, bottom[:box_width], curses.A_BOLD if focused else curses.A_DIM)
63
93
 
64
94
  def _compose_lines(self, text: str, width: int) -> list[str]:
65
95
  lines: list[str] = []
@@ -93,8 +123,12 @@ class CobaltTUI:
93
123
 
94
124
  sidebar_w = min(26, max(20, width // 4))
95
125
  sidebar_x = 1
96
- sidebar_y = 1
97
- sidebar_h = max(8, height - 4)
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
+ sidebar_y = 6
131
+ sidebar_h = max(8, height - 9)
98
132
  self._draw_box(stdscr, sidebar_y, sidebar_x, sidebar_h, sidebar_w, " Chats ")
99
133
 
100
134
  chat_files = self.chat_store.list_chats()[-8:]
@@ -112,20 +146,12 @@ class CobaltTUI:
112
146
 
113
147
  main_x = sidebar_x + sidebar_w + 2
114
148
  main_w = max(20, width - main_x - 2)
115
- main_y = 1
116
- main_h = max(8, height - 4)
149
+ main_y = 6
150
+ main_h = max(8, height - 9)
117
151
  self._draw_box(stdscr, main_y, main_x, main_h, main_w, f" {self.history.title or 'conversation'} ")
118
152
 
119
- if not self.history.messages:
120
- for idx, line in enumerate(self.ascii_lines):
121
- y = main_y + 2 + idx
122
- x = main_x + max(0, (main_w - len(line)) // 2)
123
- stdscr.addstr(y, x, line[: max(0, main_w - 2)])
124
- prompt = f"> {input_text}" if input_text else "> "
125
- prompt_y = main_y + 2 + len(self.ascii_lines) + 2
126
- prompt_x = main_x + max(0, (main_w - len(prompt)) // 2)
127
- stdscr.addstr(prompt_y, prompt_x, prompt[: max(0, main_w - 2)], curses.A_REVERSE)
128
- else:
153
+ 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:
129
155
  rows: list[str] = []
130
156
  for message in self.history.latest(18):
131
157
  role = message.get("role", "user").upper()
@@ -135,16 +161,15 @@ class CobaltTUI:
135
161
  rows.append(f"{prefix}{line}" if prefix and line else line)
136
162
  prefix = ""
137
163
 
138
- visible = rows[-max(4, main_h - 4):]
164
+ visible = rows[-max(1, box_y - main_y - 2):]
139
165
  for idx, line in enumerate(visible[: main_h - 4]):
140
166
  y = main_y + 1 + idx
167
+ if y >= box_y:
168
+ break
141
169
  clipped = line[: max(0, main_w - 4)]
142
170
  stdscr.addstr(y, main_x + 2, clipped)
143
171
 
144
- prompt = f"> {input_text}" if input_text else "> "
145
- prompt_y = main_y + main_h - 3
146
- prompt_x = main_x + max(0, (main_w - len(prompt)) // 2)
147
- stdscr.addstr(prompt_y, prompt_x, prompt[: max(0, main_w - 2)], curses.A_REVERSE)
172
+ self._draw_input_box(stdscr, box_x, box_y, box_width, box_height, input_lines, self.input_focused)
148
173
 
149
174
  if status:
150
175
  status_line = status[: max(0, width - 1)]
@@ -165,17 +190,22 @@ class CobaltTUI:
165
190
 
166
191
  agent = CobaltAgent(settings=self.settings)
167
192
  agent.history = self.history
168
- agent.process_stream(input_text.strip(), on_chunk=on_chunk)
193
+ response = agent.process_stream(input_text.strip(), on_chunk=on_chunk)
169
194
  self.history = agent.history
170
195
  self.current_chat = self.history
171
196
  self.saved_chats = self.chat_store.list_chats()
197
+ if response and not buffer:
198
+ buffer = response
199
+ if not buffer:
200
+ raise RuntimeError("The assistant returned an empty response")
201
+ self._render(stdscr, "", "")
172
202
  return buffer
173
203
 
174
204
  def _handle_mouse_click(self, x: int, y: int) -> bool:
175
205
  sidebar_w = min(26, max(20, curses.COLS // 4))
176
206
  sidebar_x = 1
177
- sidebar_y = 1
178
- sidebar_h = max(8, curses.LINES - 4)
207
+ sidebar_y = 6
208
+ sidebar_h = max(8, curses.LINES - 9)
179
209
 
180
210
  if sidebar_x <= x < sidebar_x + sidebar_w and sidebar_y <= y < sidebar_y + sidebar_h:
181
211
  chat_files = self.chat_store.list_chats()[-8:]
@@ -190,6 +220,16 @@ class CobaltTUI:
190
220
  return True
191
221
  return False
192
222
 
223
+ def _click_is_in_input(self, x: int, y: int, input_text: str) -> bool:
224
+ height, width = curses.LINES, curses.COLS
225
+ sidebar_w = min(26, max(20, width // 4))
226
+ main_x = 3 + sidebar_w
227
+ main_y = 6
228
+ main_w = max(20, width - main_x - 2)
229
+ main_h = max(8, height - 9)
230
+ box_x, box_y, box_width, box_height, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
231
+ return box_x <= x < box_x + box_width and box_y <= y < box_y + box_height
232
+
193
233
  def run(self) -> int:
194
234
  def _main(stdscr):
195
235
  curses.curs_set(0)
@@ -202,6 +242,7 @@ class CobaltTUI:
202
242
  curses.noecho()
203
243
  curses.mousemask(curses.BUTTON1_PRESSED | curses.BUTTON1_RELEASED | curses.BUTTON1_CLICKED)
204
244
  input_text = ""
245
+ self.input_focused = True
205
246
  status = ""
206
247
 
207
248
  while True:
@@ -210,25 +251,32 @@ class CobaltTUI:
210
251
 
211
252
  if ch == curses.KEY_MOUSE:
212
253
  _, x, y, _, button_state = curses.getmouse()
254
+ if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._click_is_in_input(x, y, input_text):
255
+ self.input_focused = True
256
+ continue
213
257
  if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._handle_mouse_click(x, y):
214
- status = "Chat selected"
258
+ status = ""
215
259
  input_text = ""
260
+ self.input_focused = False
216
261
  continue
217
262
 
218
263
  if ch == 3:
219
264
  break
220
- if self._should_handle_shortcut(ch):
221
- if ch == 14:
265
+ if ch == 27:
266
+ self.input_focused = False
267
+ continue
268
+ if self._should_handle_shortcut(ch, self.input_focused):
269
+ if ch in (14, ord("n")):
222
270
  new_chat = self.chat_store.new_chat(title="New chat")
223
271
  self.current_chat = new_chat
224
272
  self.history = self.current_chat
225
273
  self.selected_chat_path = str(self.current_chat.path)
226
274
  self.chat_files = self.chat_store.list_chats()
227
275
  input_text = ""
228
- status = "New chat started"
276
+ status = ""
229
277
  continue
230
278
 
231
- if ch == 4 and self.selected_chat_path:
279
+ if ch in (4, ord("d")) and self.selected_chat_path:
232
280
  target = Path(self.selected_chat_path)
233
281
  if target.exists():
234
282
  self.chat_store.delete_chat(target)
@@ -242,7 +290,7 @@ class CobaltTUI:
242
290
  self.history = self.current_chat
243
291
  self.selected_chat_path = str(self.current_chat.path)
244
292
  input_text = ""
245
- status = "Chat deleted"
293
+ status = ""
246
294
  continue
247
295
  if ch in (10, 13, curses.KEY_ENTER):
248
296
  if input_text.strip():
@@ -251,7 +299,7 @@ class CobaltTUI:
251
299
  self.history.title = self._suggest_chat_title(input_text.strip())
252
300
  self.history.save()
253
301
  self._stream_response(stdscr, input_text)
254
- status = "Response received"
302
+ status = ""
255
303
  except (OSError, RuntimeError, ValueError) as exc:
256
304
  self.history.add("assistant", f"Error: {exc}")
257
305
  status = ""
@@ -263,9 +311,8 @@ class CobaltTUI:
263
311
  if ch in (curses.KEY_RESIZE,):
264
312
  continue
265
313
  if 32 <= ch <= 126:
314
+ self.input_focused = True
266
315
  input_text += chr(ch)
267
- elif ch == 27:
268
- break
269
316
 
270
317
  return 0
271
318
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cobalt-cli-linux
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -77,6 +77,8 @@ 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.
81
+
80
82
  You can also pass arguments directly:
81
83
 
82
84
  ```bash
@@ -51,9 +51,11 @@ def test_tui_shortcuts_ignore_typed_letters() -> None:
51
51
 
52
52
  assert tui._should_handle_shortcut(14) is True
53
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
54
56
  assert tui._should_handle_shortcut(ord("q")) is False
55
- assert tui._should_handle_shortcut(ord("n")) is False
56
- assert tui._should_handle_shortcut(ord("d")) is False
57
+ assert tui._should_handle_shortcut(ord("n"), True) is False
58
+ assert tui._should_handle_shortcut(ord("d"), True) is False
57
59
 
58
60
 
59
61
  def test_q_is_not_a_shortcut_when_typing() -> None:
@@ -124,6 +126,12 @@ def test_stream_error_reads_response_before_inspecting_body(monkeypatch) -> None
124
126
  raise AssertionError("Expected streamed API error")
125
127
 
126
128
 
129
+ def test_tui_empty_chat_does_not_load_prompt_art_as_body_content(tmp_path: Path) -> None:
130
+ tui = CobaltTUI(settings=Settings())
131
+
132
+ assert not hasattr(tui, "ascii_lines")
133
+
134
+
127
135
  def test_write_file_tool_creates_and_verifies_file(tmp_path: Path) -> None:
128
136
  agent = CobaltAgent(settings=Settings(), history=ConversationHistory(tmp_path / "history.json"))
129
137
  target = tmp_path / "test.txt"