cobalt-cli-linux 0.1.0__tar.gz → 0.1.2__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.0 → cobalt_cli_linux-0.1.2}/PKG-INFO +5 -1
  2. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/README.md +4 -0
  3. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/pyproject.toml +4 -1
  4. cobalt_cli_linux-0.1.2/src/cobalt_cli_linux/ASCII.txt +5 -0
  5. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/agent.py +50 -20
  6. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/cli.py +2 -6
  7. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/groq_client.py +31 -28
  8. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/tui.py +88 -38
  9. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/PKG-INFO +5 -1
  10. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/SOURCES.txt +1 -0
  11. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/tests/test_cli.py +95 -4
  12. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/setup.cfg +0 -0
  13. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/__init__.py +0 -0
  14. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/__main__.py +0 -0
  15. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/config.py +0 -0
  16. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/deepseek_client.py +0 -0
  17. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/executor.py +0 -0
  18. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/history.py +0 -0
  19. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
  20. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
  21. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
  22. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/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.0
3
+ Version: 0.1.2
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -35,6 +35,8 @@ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It ca
35
35
  - File read/write operations for scriptable agent workflows
36
36
  - PyPI-ready package metadata and console script entrypoint
37
37
 
38
+ When the agent emits a `WRITE_FILE` action, Cobalt creates the requested file using UTF-8 and verifies that the file exists with the expected contents before reporting success.
39
+
38
40
  ## Installation
39
41
 
40
42
  ```bash
@@ -75,6 +77,8 @@ cobalt --interactive
75
77
 
76
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.
77
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
+
78
82
  You can also pass arguments directly:
79
83
 
80
84
  ```bash
@@ -10,6 +10,8 @@ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It ca
10
10
  - File read/write operations for scriptable agent workflows
11
11
  - PyPI-ready package metadata and console script entrypoint
12
12
 
13
+ When the agent emits a `WRITE_FILE` action, Cobalt creates the requested file using UTF-8 and verifies that the file exists with the expected contents before reporting success.
14
+
13
15
  ## Installation
14
16
 
15
17
  ```bash
@@ -50,6 +52,8 @@ cobalt --interactive
50
52
 
51
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.
52
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
+
53
57
  You can also pass arguments directly:
54
58
 
55
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.0"
7
+ version = "0.1.2"
8
8
  description = "Agentic Groq-powered CLI assistant for Debian Linux"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -45,5 +45,8 @@ package-dir = {"" = "src"}
45
45
  [tool.setuptools.packages.find]
46
46
  where = ["src"]
47
47
 
48
+ [tool.setuptools.package-data]
49
+ cobalt_cli_linux = ["ASCII.txt"]
50
+
48
51
  [tool.pytest.ini_options]
49
52
  pythonpath = ["src"]
@@ -0,0 +1,5 @@
1
+ ____ ___ ____ _ _ _____ ____ _ ___
2
+ / ___/ _ \| __ ) / \ | | |_ _| / ___| | |_ _|
3
+ | | | | | | _ \ / _ \ | | | | | | | | | |
4
+ | |__| |_| | |_) / ___ \| |___| | | |___| |___ | |
5
+ \____\___/|____/_/ \_\_____|_| \____|_____|___|
@@ -56,24 +56,49 @@ class CobaltAgent:
56
56
  path = Path(path_text.strip())
57
57
  path.parent.mkdir(parents=True, exist_ok=True)
58
58
  path.write_text(content, encoding="utf-8")
59
+ if not path.is_file() or path.read_text(encoding="utf-8") != content:
60
+ raise OSError(f"File verification failed for {path}")
59
61
  return f"Wrote {path}"
60
62
  raise ValueError(f"Unsupported tool: {tool_name}")
61
63
 
62
64
  def _extract_tools(self, response: str) -> list[tuple[str, str]]:
63
65
  tools: list[tuple[str, str]] = []
64
- for line in response.splitlines():
65
- match = re.match(r"^(COMMAND|READ_FILE|WRITE_FILE):\s*(.*)$", line.strip())
66
- if match:
67
- tools.append((match.group(1), match.group(2)))
66
+ lines = response.splitlines()
67
+ index = 0
68
+ while index < len(lines):
69
+ match = re.match(r"^(COMMAND|READ_FILE|WRITE_FILE):\s*(.*)$", lines[index].strip())
70
+ if not match:
71
+ index += 1
68
72
  continue
69
- if "WRITE_FILE:" in response:
70
- match = re.search(r"WRITE_FILE:\s*(.+?)\n(.+)", response, re.DOTALL)
71
- if match:
72
- path = match.group(1).strip()
73
- content = match.group(2)
74
- tools.append(("WRITE_FILE", f"{path}\n{content}"))
73
+
74
+ tool_name, payload = match.groups()
75
+ if tool_name == "WRITE_FILE":
76
+ content_lines: list[str] = []
77
+ index += 1
78
+ while index < len(lines) and not re.match(r"^(COMMAND|READ_FILE|WRITE_FILE):\s*", lines[index].strip()):
79
+ content_lines.append(lines[index])
80
+ index += 1
81
+ tools.append((tool_name, f"{payload}\n{chr(10).join(content_lines)}"))
82
+ continue
83
+
84
+ tools.append((tool_name, payload))
85
+ index += 1
75
86
  return tools
76
87
 
88
+ def _tool_results(self, response: str) -> list[str]:
89
+ results: list[str] = []
90
+ for tool_name, payload in self._extract_tools(response):
91
+ try:
92
+ result = self._execute_tool(tool_name, payload)
93
+ results.append(f"{tool_name} result:\n{result}")
94
+ except (OSError, ValueError, RuntimeError) as exc: # pragma: no cover - defensive path
95
+ results.append(f"{tool_name} error:\n{exc}")
96
+ return results
97
+
98
+ def _follow_up_messages(self, tool_results: list[str]) -> list[dict[str, str]]:
99
+ final_prompt = "Here are the tool result(s) from my previous action:\n\n" + "\n\n".join(tool_results) + "\n\nPlease provide the final user-facing response based on these results."
100
+ return [{"role": "system", "content": SYSTEM_PROMPT}] + self.history.latest(20) + [{"role": "user", "content": final_prompt}]
101
+
77
102
  def process(self, prompt: str) -> str:
78
103
  if not self.settings.api_key_configured:
79
104
  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.")
@@ -87,17 +112,9 @@ class CobaltAgent:
87
112
  self.history.add("assistant", response)
88
113
  return response
89
114
 
90
- tool_results: list[str] = []
91
- for tool_name, payload in tool_calls:
92
- try:
93
- result = self._execute_tool(tool_name, payload)
94
- tool_results.append(f"{tool_name} result:\n{result}")
95
- except Exception as exc: # pragma: no cover - defensive path
96
- tool_results.append(f"{tool_name} error:\n{exc}")
97
-
98
- final_prompt = "Here are the tool result(s) from my previous action:\n\n" + "\n\n".join(tool_results) + "\n\nPlease provide the final user-facing response based on these results."
115
+ tool_results = self._tool_results(response)
99
116
  follow_up = self.client.chat_completion(
100
- [{"role": "system", "content": SYSTEM_PROMPT}] + self.history.latest(20) + [{"role": "user", "content": final_prompt}],
117
+ self._follow_up_messages(tool_results),
101
118
  temperature=self.settings.temperature,
102
119
  max_tokens=self.settings.max_tokens,
103
120
  )
@@ -128,6 +145,19 @@ class CobaltAgent:
128
145
  if on_chunk is not None:
129
146
  on_chunk(chunk)
130
147
 
148
+ tool_results = self._tool_results(buffer)
149
+ if tool_results:
150
+ follow_up_buffer = ""
151
+ for chunk in self.client.chat_completion_stream(
152
+ self._follow_up_messages(tool_results),
153
+ temperature=self.settings.temperature,
154
+ max_tokens=self.settings.max_tokens,
155
+ ):
156
+ follow_up_buffer += chunk
157
+ if on_chunk is not None:
158
+ on_chunk(chunk)
159
+ buffer = follow_up_buffer
160
+
131
161
  if self.history.messages and self.history.messages[-1].get("role") == "assistant":
132
162
  self.history.messages[-1]["content"] = buffer
133
163
  self.history.save()
@@ -35,20 +35,16 @@ def main(argv: list[str] | None = None) -> int:
35
35
  max_tokens=args.max_tokens if args.max_tokens is not None else 1024,
36
36
  )
37
37
 
38
- if args.interactive:
38
+ if args.interactive or not args.prompt:
39
39
  return CobaltTUI(settings).run()
40
40
 
41
41
  prompt = " ".join(args.prompt)
42
- if not prompt:
43
- print("Please provide a prompt or use --interactive for the full-screen UI.", file=sys.stderr)
44
- return 2
45
-
46
42
  try:
47
43
  agent = CobaltAgent(settings=settings)
48
44
  response = agent.process(prompt)
49
45
  print(response)
50
46
  return 0
51
- except Exception as exc:
47
+ except (OSError, RuntimeError, ValueError) as exc:
52
48
  print(f"Error: {exc}", file=sys.stderr)
53
49
  return 1
54
50
 
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- from typing import Iterator
4
+ from collections.abc import Iterator
5
+ from typing import ClassVar
5
6
 
6
7
  import httpx
7
8
 
@@ -11,7 +12,7 @@ class GroqAPIError(RuntimeError):
11
12
 
12
13
 
13
14
  class GroqClient:
14
- DEFAULT_MODELS = [
15
+ DEFAULT_MODELS: ClassVar[list[str]] = [
15
16
  "llama-3.3-70b-versatile",
16
17
  "llama-3.1-8b-instant",
17
18
  "llama-3.3-8b",
@@ -128,29 +129,31 @@ class GroqClient:
128
129
  "Content-Type": "application/json",
129
130
  }
130
131
 
131
- with httpx.Client(timeout=60.0) as client:
132
- with client.stream("POST", f"{self.base_url}/chat/completions", headers=headers, json=payload) as response:
133
- if response.status_code != 200:
134
- try:
135
- detail = response.json()
136
- except ValueError:
137
- detail = response.text
138
- raise GroqAPIError(f"Groq API request failed ({response.status_code}): {detail}")
139
-
140
- for line in response.iter_lines():
141
- if not line or not line.startswith("data:"):
142
- continue
143
- data = line[5:].strip()
144
- if data == "[DONE]":
145
- break
146
- try:
147
- event = json.loads(data)
148
- except json.JSONDecodeError:
149
- continue
150
- choices = event.get("choices", [])
151
- if not choices:
152
- continue
153
- delta = choices[0].get("delta", {})
154
- content = delta.get("content")
155
- if isinstance(content, str):
156
- yield content
132
+ with httpx.Client(timeout=60.0) as client, client.stream(
133
+ "POST", f"{self.base_url}/chat/completions", headers=headers, json=payload
134
+ ) as response:
135
+ if response.status_code != 200:
136
+ response.read()
137
+ try:
138
+ detail = response.json()
139
+ except ValueError:
140
+ detail = response.text
141
+ raise GroqAPIError(f"Groq API request failed ({response.status_code}): {detail}")
142
+
143
+ for line in response.iter_lines():
144
+ if not line or not line.startswith("data:"):
145
+ continue
146
+ data = line[5:].strip()
147
+ if data == "[DONE]":
148
+ break
149
+ try:
150
+ event = json.loads(data)
151
+ except json.JSONDecodeError:
152
+ continue
153
+ choices = event.get("choices", [])
154
+ if not choices:
155
+ continue
156
+ delta = choices[0].get("delta", {})
157
+ content = delta.get("content")
158
+ if isinstance(content, str):
159
+ yield content
@@ -6,7 +6,7 @@ import textwrap
6
6
  from pathlib import Path
7
7
 
8
8
  from .agent import CobaltAgent
9
- from .history import ChatStore, ConversationHistory
9
+ from .history import ChatStore
10
10
 
11
11
 
12
12
  class CobaltTUI:
@@ -23,9 +23,12 @@ class CobaltTUI:
23
23
  self.history = self.current_chat
24
24
  self.saved_chats = self.chat_files
25
25
  self.ascii_lines = self._load_ascii_art()
26
+ self.input_focused = True
26
27
 
27
28
  def _load_ascii_art(self) -> list[str]:
28
- ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
29
+ ascii_path = Path(__file__).with_name("ASCII.txt")
30
+ if not ascii_path.exists():
31
+ ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
29
32
  if not ascii_path.exists():
30
33
  return ["COBALT"]
31
34
  return [line.rstrip() for line in ascii_path.read_text(encoding="utf-8").splitlines() if line.strip()]
@@ -56,10 +59,38 @@ class CobaltTUI:
56
59
  title = " ".join(words[:4]).strip()
57
60
  return title.title() if title else "New chat"
58
61
 
59
- def _should_handle_shortcut(self, ch: int, input_text: str) -> bool:
60
- if input_text.strip():
61
- return False
62
- return ch in (ord("n"), ord("d"))
62
+ def _should_handle_shortcut(self, ch: int, input_focused: bool = False) -> bool:
63
+ return not input_focused and ch in (14, 4, ord("n"), ord("d"))
64
+
65
+ 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]]:
66
+ box_width = max(20, main_w - 4)
67
+ text_width = max(10, box_width - 6)
68
+ input_lines = textwrap.wrap(
69
+ input_text,
70
+ width=text_width,
71
+ replace_whitespace=False,
72
+ drop_whitespace=False,
73
+ break_long_words=True,
74
+ break_on_hyphens=False,
75
+ ) or [""]
76
+ max_lines = max(1, min(6, main_h - 6))
77
+ input_lines = input_lines[-max_lines:]
78
+ box_height = len(input_lines) + 3
79
+ box_x = main_x + 2
80
+ box_y = main_y + main_h - box_height - 1
81
+ return box_x, box_y, box_width, box_height, input_lines
82
+
83
+ 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:
84
+ rule = "-" * max(0, box_width - 2)
85
+ top_label = " Ask AI "
86
+ top = f"-{top_label}{rule}"
87
+ bottom_label = " [Enter to submit] "
88
+ bottom = f"-{'-' * max(0, box_width - len(bottom_label) - 2)}{bottom_label}-"
89
+ stdscr.addstr(box_y, box_x, top[:box_width])
90
+ for index, line in enumerate(input_lines):
91
+ prompt = "> " if index == 0 else " "
92
+ stdscr.addstr(box_y + 1 + index, box_x, f"{prompt}{line}"[:box_width])
93
+ stdscr.addstr(box_y + box_height - 1, box_x, bottom[:box_width], curses.A_BOLD if focused else curses.A_DIM)
63
94
 
64
95
  def _compose_lines(self, text: str, width: int) -> list[str]:
65
96
  lines: list[str] = []
@@ -67,8 +98,7 @@ class CobaltTUI:
67
98
  if not paragraph:
68
99
  lines.append("")
69
100
  continue
70
- for wrapped in textwrap.wrap(paragraph, width=max(15, width), break_long_words=False, break_on_hyphens=False):
71
- lines.append(wrapped)
101
+ lines.extend(textwrap.wrap(paragraph, width=max(15, width), break_long_words=False, break_on_hyphens=False))
72
102
  return lines
73
103
 
74
104
  def _draw_box(self, stdscr, top: int, left: int, height: int, width: int, title: str | None = None) -> None:
@@ -89,6 +119,9 @@ class CobaltTUI:
89
119
  stdscr.bkgd(" ", curses.color_pair(0))
90
120
  height, width = stdscr.getmaxyx()
91
121
 
122
+ header = " Cobalt Code | terminal agent "
123
+ stdscr.addstr(0, 1, header[: max(0, width - 2)], curses.A_BOLD)
124
+
92
125
  sidebar_w = min(26, max(20, width // 4))
93
126
  sidebar_x = 1
94
127
  sidebar_y = 1
@@ -112,41 +145,41 @@ class CobaltTUI:
112
145
  main_w = max(20, width - main_x - 2)
113
146
  main_y = 1
114
147
  main_h = max(8, height - 4)
115
- self._draw_box(stdscr, main_y, main_x, main_h, main_w, " conversation ")
148
+ self._draw_box(stdscr, main_y, main_x, main_h, main_w, f" {self.history.title or 'conversation'} ")
116
149
 
150
+ box_x, box_y, box_width, box_height, input_lines = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
117
151
  if not self.history.messages:
152
+ content_height = max(1, box_y - main_y - 1)
153
+ art_start = main_y + max(1, (content_height - len(self.ascii_lines)) // 2)
118
154
  for idx, line in enumerate(self.ascii_lines):
119
- y = main_y + 2 + idx
155
+ y = art_start + idx
120
156
  x = main_x + max(0, (main_w - len(line)) // 2)
121
- stdscr.addstr(y, x, line[: max(0, main_w - 2)])
122
- prompt = f"> {input_text}" if input_text else "> "
123
- prompt_y = main_y + 2 + len(self.ascii_lines) + 2
124
- prompt_x = main_x + max(0, (main_w - len(prompt)) // 2)
125
- stdscr.addstr(prompt_y, prompt_x, prompt[: max(0, main_w - 2)], curses.A_REVERSE)
157
+ if y < box_y:
158
+ stdscr.addstr(y, x, line[: max(0, main_w - 2)])
126
159
  else:
127
160
  rows: list[str] = []
128
161
  for message in self.history.latest(18):
129
162
  role = message.get("role", "user").upper()
130
163
  content = message.get("content", "")
131
- prefix = f"[{role}] "
164
+ prefix = f"{role.lower()} "
132
165
  for line in self._compose_lines(content, max(12, main_w - 8 - len(prefix))):
133
166
  rows.append(f"{prefix}{line}" if prefix and line else line)
134
167
  prefix = ""
135
168
 
136
- visible = rows[-max(4, main_h - 4):]
169
+ visible = rows[-max(1, box_y - main_y - 2):]
137
170
  for idx, line in enumerate(visible[: main_h - 4]):
138
171
  y = main_y + 1 + idx
172
+ if y >= box_y:
173
+ break
139
174
  clipped = line[: max(0, main_w - 4)]
140
175
  stdscr.addstr(y, main_x + 2, clipped)
141
176
 
142
- prompt = f"> {input_text}" if input_text else "> "
143
- prompt_y = main_y + main_h - 3
144
- prompt_x = main_x + max(0, (main_w - len(prompt)) // 2)
145
- stdscr.addstr(prompt_y, prompt_x, prompt[: max(0, main_w - 2)], curses.A_REVERSE)
177
+ self._draw_input_box(stdscr, box_x, box_y, box_width, box_height, input_lines, self.input_focused)
146
178
 
147
- status_line = status[: max(0, width - 1)]
148
- stdscr.addstr(height - 1, 0, status_line)
149
- stdscr.clrtoeol()
179
+ if status:
180
+ status_line = status[: max(0, width - 1)]
181
+ stdscr.addstr(height - 1, 0, status_line)
182
+ stdscr.clrtoeol()
150
183
  stdscr.refresh()
151
184
 
152
185
  def _stream_response(self, stdscr, input_text: str) -> str:
@@ -158,7 +191,7 @@ class CobaltTUI:
158
191
  if self.history.messages and self.history.messages[-1]["role"] == "assistant":
159
192
  self.history.messages[-1]["content"] = buffer
160
193
  self.history.save()
161
- self._render(stdscr, input_text, "Streaming...")
194
+ self._render(stdscr, input_text, "")
162
195
 
163
196
  agent = CobaltAgent(settings=self.settings)
164
197
  agent.history = self.history
@@ -187,6 +220,16 @@ class CobaltTUI:
187
220
  return True
188
221
  return False
189
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 = 1
228
+ main_w = max(20, width - main_x - 2)
229
+ main_h = max(8, height - 4)
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
+
190
233
  def run(self) -> int:
191
234
  def _main(stdscr):
192
235
  curses.curs_set(0)
@@ -199,7 +242,8 @@ class CobaltTUI:
199
242
  curses.noecho()
200
243
  curses.mousemask(curses.BUTTON1_PRESSED | curses.BUTTON1_RELEASED | curses.BUTTON1_CLICKED)
201
244
  input_text = ""
202
- status = "Ctrl+C or q to quit • Enter to send • n new chat • d delete chat"
245
+ self.input_focused = True
246
+ status = ""
203
247
 
204
248
  while True:
205
249
  self._render(stdscr, input_text, status)
@@ -207,16 +251,22 @@ class CobaltTUI:
207
251
 
208
252
  if ch == curses.KEY_MOUSE:
209
253
  _, x, y, _, button_state = curses.getmouse()
210
- if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED):
211
- if self._handle_mouse_click(x, y):
212
- status = "Chat selected"
213
- input_text = ""
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
257
+ if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._handle_mouse_click(x, y):
258
+ status = "Chat selected"
259
+ input_text = ""
260
+ self.input_focused = False
214
261
  continue
215
262
 
216
- if ch in (ord("q"), 3):
263
+ if ch == 3:
217
264
  break
218
- if ch in (ord("n"), ord("d")) and self._should_handle_shortcut(ch, input_text):
219
- if ch == ord("n"):
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")):
220
270
  new_chat = self.chat_store.new_chat(title="New chat")
221
271
  self.current_chat = new_chat
222
272
  self.history = self.current_chat
@@ -226,7 +276,7 @@ class CobaltTUI:
226
276
  status = "New chat started"
227
277
  continue
228
278
 
229
- if ch == ord("d") and self.selected_chat_path:
279
+ if ch in (4, ord("d")) and self.selected_chat_path:
230
280
  target = Path(self.selected_chat_path)
231
281
  if target.exists():
232
282
  self.chat_store.delete_chat(target)
@@ -250,8 +300,9 @@ class CobaltTUI:
250
300
  self.history.save()
251
301
  self._stream_response(stdscr, input_text)
252
302
  status = "Response received"
253
- except Exception as exc:
254
- status = f"Error: {exc}"
303
+ except (OSError, RuntimeError, ValueError) as exc:
304
+ self.history.add("assistant", f"Error: {exc}")
305
+ status = ""
255
306
  input_text = ""
256
307
  continue
257
308
  if ch in (curses.KEY_BACKSPACE, 127, 8):
@@ -260,9 +311,8 @@ class CobaltTUI:
260
311
  if ch in (curses.KEY_RESIZE,):
261
312
  continue
262
313
  if 32 <= ch <= 126:
314
+ self.input_focused = True
263
315
  input_text += chr(ch)
264
- elif ch == 27:
265
- break
266
316
 
267
317
  return 0
268
318
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cobalt-cli-linux
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -35,6 +35,8 @@ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It ca
35
35
  - File read/write operations for scriptable agent workflows
36
36
  - PyPI-ready package metadata and console script entrypoint
37
37
 
38
+ When the agent emits a `WRITE_FILE` action, Cobalt creates the requested file using UTF-8 and verifies that the file exists with the expected contents before reporting success.
39
+
38
40
  ## Installation
39
41
 
40
42
  ```bash
@@ -75,6 +77,8 @@ cobalt --interactive
75
77
 
76
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.
77
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
+
78
82
  You can also pass arguments directly:
79
83
 
80
84
  ```bash
@@ -1,5 +1,6 @@
1
1
  README.md
2
2
  pyproject.toml
3
+ src/cobalt_cli_linux/ASCII.txt
3
4
  src/cobalt_cli_linux/__init__.py
4
5
  src/cobalt_cli_linux/__main__.py
5
6
  src/cobalt_cli_linux/agent.py
@@ -1,5 +1,7 @@
1
1
  from pathlib import Path
2
2
 
3
+ from cobalt_cli_linux import cli
4
+ from cobalt_cli_linux.agent import CobaltAgent
3
5
  from cobalt_cli_linux.config import Settings
4
6
  from cobalt_cli_linux.executor import ShellExecutor
5
7
  from cobalt_cli_linux.groq_client import GroqClient
@@ -47,10 +49,99 @@ def test_chat_store_delete_chat(tmp_path: Path) -> None:
47
49
  def test_tui_shortcuts_ignore_typed_letters() -> None:
48
50
  tui = CobaltTUI(settings=Settings())
49
51
 
50
- assert tui._should_handle_shortcut(ord("n"), "") is True
51
- assert tui._should_handle_shortcut(ord("d"), "") is True
52
- assert tui._should_handle_shortcut(ord("n"), "note") is False
53
- assert tui._should_handle_shortcut(ord("d"), "delete") is False
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
+
60
+
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
65
+
66
+
67
+ def test_no_prompt_launches_tui(monkeypatch) -> None:
68
+ launched = []
69
+
70
+ class FakeTUI:
71
+ def __init__(self, settings):
72
+ launched.append(settings)
73
+
74
+ def run(self):
75
+ return 0
76
+
77
+ monkeypatch.setattr(cli, "CobaltTUI", FakeTUI)
78
+
79
+ assert cli.main([]) == 0
80
+ assert launched
81
+
82
+
83
+ def test_stream_error_reads_response_before_inspecting_body(monkeypatch) -> None:
84
+ class FakeResponse:
85
+ status_code = 401
86
+ text = "unauthorized"
87
+ was_read = False
88
+
89
+ def read(self):
90
+ self.was_read = True
91
+
92
+ def json(self):
93
+ assert self.was_read is True
94
+ return {"error": {"message": "unauthorized"}}
95
+
96
+ def __enter__(self):
97
+ return self
98
+
99
+ def __exit__(self, exc_type, exc, tb):
100
+ return False
101
+
102
+ class FakeStreamClient:
103
+ def __init__(self, timeout=None):
104
+ self.response = FakeResponse()
105
+
106
+ def __enter__(self):
107
+ return self
108
+
109
+ def __exit__(self, exc_type, exc, tb):
110
+ return False
111
+
112
+ def get(self, url, headers=None):
113
+ return type("Response", (), {"status_code": 200, "json": lambda self: {"data": []}})()
114
+
115
+ def stream(self, method, url, headers=None, json=None):
116
+ return self.response
117
+
118
+ monkeypatch.setattr("cobalt_cli_linux.groq_client.httpx.Client", FakeStreamClient)
119
+ client = GroqClient("test-key", model="llama-3.1-8b-instant")
120
+
121
+ try:
122
+ next(client.chat_completion_stream([{"role": "user", "content": "hello"}]))
123
+ except RuntimeError as exc:
124
+ assert "401" in str(exc)
125
+ else:
126
+ raise AssertionError("Expected streamed API error")
127
+
128
+
129
+ def test_write_file_tool_creates_and_verifies_file(tmp_path: Path) -> None:
130
+ agent = CobaltAgent(settings=Settings(), history=ConversationHistory(tmp_path / "history.json"))
131
+ target = tmp_path / "test.txt"
132
+
133
+ result = agent._execute_tool("WRITE_FILE", f"{target}\nhello")
134
+
135
+ assert result == f"Wrote {target}"
136
+ assert target.read_text(encoding="utf-8") == "hello"
137
+
138
+
139
+ def test_write_file_directive_is_parsed_once() -> None:
140
+ agent = CobaltAgent(settings=Settings())
141
+
142
+ tools = agent._extract_tools("WRITE_FILE: test.txt\nhello\nworld")
143
+
144
+ assert tools == [("WRITE_FILE", "test.txt\nhello\nworld")]
54
145
 
55
146
 
56
147
  def test_shell_executor_runs_command() -> None: