cobalt-cli-linux 0.1.2__tar.gz → 0.1.4__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.2 → cobalt_cli_linux-0.1.4}/PKG-INFO +4 -2
  2. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/README.md +3 -1
  3. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/pyproject.toml +1 -1
  4. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/agent.py +15 -2
  5. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/groq_client.py +19 -2
  6. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/history.py +5 -1
  7. cobalt_cli_linux-0.1.4/src/cobalt_cli_linux/profiles.py +67 -0
  8. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/tui.py +144 -61
  9. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/PKG-INFO +4 -2
  10. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/SOURCES.txt +1 -0
  11. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/tests/test_cli.py +27 -13
  12. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/setup.cfg +0 -0
  13. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/ASCII.txt +0 -0
  14. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/__init__.py +0 -0
  15. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/__main__.py +0 -0
  16. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/cli.py +0 -0
  17. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/config.py +0 -0
  18. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/deepseek_client.py +0 -0
  19. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux/executor.py +0 -0
  20. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
  21. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
  22. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
  23. {cobalt_cli_linux-0.1.2 → cobalt_cli_linux-0.1.4}/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.2
3
+ Version: 0.1.4
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -77,7 +77,9 @@ 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.
81
83
 
82
84
  You can also pass arguments directly:
83
85
 
@@ -52,7 +52,9 @@ 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.
56
58
 
57
59
  You can also pass arguments directly:
58
60
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cobalt-cli-linux"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Agentic Groq-powered CLI assistant for Debian Linux"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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,67 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timedelta, timezone
5
+ from pathlib import Path
6
+
7
+
8
+ class ProfileStore:
9
+ def __init__(self, path: str | Path = "~/.cobalt/profiles.json") -> None:
10
+ self.path = Path(path).expanduser()
11
+ self.path.parent.mkdir(parents=True, exist_ok=True)
12
+ self.data = self._load()
13
+
14
+ def _load(self) -> dict:
15
+ if not self.path.exists():
16
+ return {"profiles": {"guest": {"prompts": []}}, "active": "guest"}
17
+ try:
18
+ value = json.loads(self.path.read_text(encoding="utf-8"))
19
+ except json.JSONDecodeError:
20
+ return {"profiles": {"guest": {"prompts": []}}, "active": "guest"}
21
+ value.setdefault("profiles", {}).setdefault("guest", {"prompts": []})
22
+ value.setdefault("active", "guest")
23
+ return value
24
+
25
+ def save(self) -> None:
26
+ self.path.write_text(json.dumps(self.data, indent=2), encoding="utf-8")
27
+
28
+ @property
29
+ def active(self) -> str:
30
+ return self.data["active"]
31
+
32
+ def create(self, name: str) -> str:
33
+ name = " ".join(name.strip().split())
34
+ if not name:
35
+ raise ValueError("Profile name cannot be empty")
36
+ self.data["profiles"].setdefault(name, {"prompts": []})
37
+ self.data["active"] = name
38
+ self.save()
39
+ return name
40
+
41
+ def delete(self, name: str | None = None) -> bool:
42
+ name = name or self.active
43
+ if name == "guest" or name not in self.data["profiles"]:
44
+ return False
45
+ del self.data["profiles"][name]
46
+ self.data["active"] = "guest"
47
+ self.save()
48
+ return True
49
+
50
+ def allow_prompt(self) -> bool:
51
+ if self.active != "guest":
52
+ return True
53
+ now = datetime.now(timezone.utc)
54
+ cutoff = now - timedelta(hours=1)
55
+ prompts = [
56
+ datetime.fromisoformat(value)
57
+ for value in self.data["profiles"]["guest"].get("prompts", [])
58
+ if datetime.fromisoformat(value) >= cutoff
59
+ ]
60
+ if len(prompts) >= 3:
61
+ self.data["profiles"]["guest"]["prompts"] = [value.isoformat() for value in prompts]
62
+ self.save()
63
+ return False
64
+ prompts.append(now)
65
+ self.data["profiles"]["guest"]["prompts"] = [value.isoformat() for value in prompts]
66
+ self.save()
67
+ return True
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
 
8
8
  from .agent import CobaltAgent
9
9
  from .history import ChatStore
10
+ from .profiles import ProfileStore
10
11
 
11
12
 
12
13
  class CobaltTUI:
@@ -22,15 +23,15 @@ class CobaltTUI:
22
23
  self.current_chat = self.chat_store.load_chat(self.selected_chat_path)
23
24
  self.history = self.current_chat
24
25
  self.saved_chats = self.chat_files
25
- self.ascii_lines = self._load_ascii_art()
26
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()
27
30
 
28
31
  def _load_ascii_art(self) -> list[str]:
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"
32
+ ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
32
33
  if not ascii_path.exists():
33
- return ["COBALT"]
34
+ return ["Cobalt CLI"]
34
35
  return [line.rstrip() for line in ascii_path.read_text(encoding="utf-8").splitlines() if line.strip()]
35
36
 
36
37
  def _refresh_chat_files(self) -> None:
@@ -59,8 +60,43 @@ class CobaltTUI:
59
60
  title = " ".join(words[:4]).strip()
60
61
  return title.title() if title else "New chat"
61
62
 
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"))
63
+ def _button_regions(self, width: int) -> dict[str, tuple[int, int, int, int]]:
64
+ delete_label = "[ Delete chat ]"
65
+ new_label = "[ New chat ]"
66
+ delete_x = max(1, width - len(delete_label) - 2)
67
+ new_x = max(1, delete_x - len(new_label) - 2)
68
+ return {
69
+ "new": (new_x, 0, len(new_label), 1),
70
+ "delete": (delete_x, 0, len(delete_label), 1),
71
+ }
72
+
73
+ def _handle_button_click(self, x: int, y: int, width: int | None = None) -> str | None:
74
+ screen_width = width if width is not None else curses.COLS
75
+ for name, (button_x, button_y, button_width, button_height) in self._button_regions(screen_width).items():
76
+ if button_x <= x < button_x + button_width and button_y <= y < button_y + button_height:
77
+ return name
78
+ return None
79
+
80
+ def _start_new_chat(self) -> None:
81
+ self.current_chat = self.chat_store.new_chat(title="New chat")
82
+ self.history = self.current_chat
83
+ self.selected_chat_path = str(self.current_chat.path)
84
+ self.chat_files = self.chat_store.list_chats()
85
+
86
+ def _delete_selected_chat(self) -> None:
87
+ if not self.selected_chat_path:
88
+ return
89
+ target = Path(self.selected_chat_path)
90
+ if not target.exists():
91
+ return
92
+ self.chat_store.delete_chat(target)
93
+ self.chat_files = self.chat_store.list_chats()
94
+ if self.chat_files:
95
+ self.selected_chat_path = str(self.chat_files[-1])
96
+ self.history = self.chat_store.load_chat(self.selected_chat_path)
97
+ self.current_chat = self.history
98
+ else:
99
+ self._start_new_chat()
64
100
 
65
101
  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
102
  box_width = max(20, main_w - 4)
@@ -101,6 +137,36 @@ class CobaltTUI:
101
137
  lines.extend(textwrap.wrap(paragraph, width=max(15, width), break_long_words=False, break_on_hyphens=False))
102
138
  return lines
103
139
 
140
+ def _format_message_lines(self, message: dict[str, str], width: int, message_index: int) -> list[tuple[str, int]]:
141
+ role = message.get("role", "user").lower()
142
+ lines: list[tuple[str, int]] = []
143
+ thinking = message.get("thinking", "")
144
+ if role == "assistant" and thinking:
145
+ marker = "[-] thinking (click to expand)" if message_index not in self.thinking_expanded else "[+] thinking"
146
+ lines.append((marker, curses.A_DIM))
147
+ if message_index in self.thinking_expanded:
148
+ for line in self._compose_lines(thinking, max(12, width - 5)):
149
+ lines.append((f" {line}", curses.A_DIM))
150
+
151
+ in_code = False
152
+ has_body = False
153
+ for raw_line in message.get("content", "").splitlines() or [""]:
154
+ if raw_line.strip().startswith("```"):
155
+ in_code = not in_code
156
+ continue
157
+ attr = curses.A_REVERSE if in_code else curses.A_NORMAL
158
+ prefix = f"{role} " if not has_body else " "
159
+ if raw_line.startswith("#") or raw_line.lstrip().startswith(('-', '*')):
160
+ attr |= curses.A_BOLD
161
+ if "`" in raw_line:
162
+ raw_line = raw_line.replace("`", "")
163
+ attr |= curses.A_REVERSE
164
+ for line in self._compose_lines(raw_line, max(12, width - len(prefix))):
165
+ lines.append((f"{prefix}{line}" if prefix.strip() else line, attr))
166
+ prefix = " "
167
+ has_body = True
168
+ return lines
169
+
104
170
  def _draw_box(self, stdscr, top: int, left: int, height: int, width: int, title: str | None = None) -> None:
105
171
  if height < 3 or width < 4:
106
172
  return
@@ -121,12 +187,17 @@ class CobaltTUI:
121
187
 
122
188
  header = " Cobalt Code | terminal agent "
123
189
  stdscr.addstr(0, 1, header[: max(0, width - 2)], curses.A_BOLD)
190
+ for name, (button_x, button_y, button_width, _) in self._button_regions(width).items():
191
+ label = "[ New chat ]" if name == "new" else "[ Delete chat ]"
192
+ stdscr.addstr(button_y, button_x, label[:button_width], curses.A_REVERSE if name == "new" else curses.A_NORMAL)
124
193
 
125
194
  sidebar_w = min(26, max(20, width // 4))
126
195
  sidebar_x = 1
127
- sidebar_y = 1
128
- sidebar_h = max(8, height - 4)
196
+ sidebar_y = 6
197
+ sidebar_h = max(8, height - 9)
129
198
  self._draw_box(stdscr, sidebar_y, sidebar_x, sidebar_h, sidebar_w, " Chats ")
199
+ profile_line = f"profile: {self.profile_store.active}"
200
+ stdscr.addstr(sidebar_y + 1, sidebar_x + 2, profile_line[: max(0, sidebar_w - 4)], curses.A_DIM)
130
201
 
131
202
  chat_files = self.chat_store.list_chats()[-8:]
132
203
  for idx, chat_path in enumerate(chat_files):
@@ -143,36 +214,30 @@ class CobaltTUI:
143
214
 
144
215
  main_x = sidebar_x + sidebar_w + 2
145
216
  main_w = max(20, width - main_x - 2)
146
- main_y = 1
147
- main_h = max(8, height - 4)
217
+ main_y = 6
218
+ main_h = max(8, height - 9)
148
219
  self._draw_box(stdscr, main_y, main_x, main_h, main_w, f" {self.history.title or 'conversation'} ")
149
220
 
150
221
  box_x, box_y, box_width, box_height, input_lines = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
151
222
  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)
154
- for idx, line in enumerate(self.ascii_lines):
155
- y = art_start + idx
223
+ art_start = main_y + 1
224
+ for index, line in enumerate(self.ascii_lines):
225
+ y = art_start + index
156
226
  x = main_x + max(0, (main_w - len(line)) // 2)
157
227
  if y < box_y:
158
228
  stdscr.addstr(y, x, line[: max(0, main_w - 2)])
159
229
  else:
160
- rows: list[str] = []
161
- for message in self.history.latest(18):
162
- role = message.get("role", "user").upper()
163
- content = message.get("content", "")
164
- prefix = f"{role.lower()} "
165
- for line in self._compose_lines(content, max(12, main_w - 8 - len(prefix))):
166
- rows.append(f"{prefix}{line}" if prefix and line else line)
167
- prefix = ""
168
-
169
- visible = rows[-max(1, box_y - main_y - 2):]
170
- for idx, line in enumerate(visible[: main_h - 4]):
230
+ formatted_rows: list[tuple[str, int, int]] = []
231
+ for message_index, message in enumerate(self.history.latest(18)):
232
+ formatted_rows.extend((line, attr, message_index) for line, attr in self._format_message_lines(message, main_w - 8, message_index))
233
+
234
+ visible = formatted_rows[-max(1, box_y - main_y - 2):]
235
+ for idx, (line, attr, _) in enumerate(visible[: main_h - 4]):
171
236
  y = main_y + 1 + idx
172
237
  if y >= box_y:
173
238
  break
174
239
  clipped = line[: max(0, main_w - 4)]
175
- stdscr.addstr(y, main_x + 2, clipped)
240
+ stdscr.addstr(y, main_x + 2, clipped, attr)
176
241
 
177
242
  self._draw_input_box(stdscr, box_x, box_y, box_width, box_height, input_lines, self.input_focused)
178
243
 
@@ -195,17 +260,28 @@ class CobaltTUI:
195
260
 
196
261
  agent = CobaltAgent(settings=self.settings)
197
262
  agent.history = self.history
198
- agent.process_stream(input_text.strip(), on_chunk=on_chunk)
263
+ def on_thinking(chunk: str) -> None:
264
+ if self.history.messages and self.history.messages[-1]["role"] == "assistant":
265
+ self.history.messages[-1]["thinking"] = self.history.messages[-1].get("thinking", "") + chunk
266
+ self.history.save()
267
+ self._render(stdscr, input_text, "")
268
+
269
+ response = agent.process_stream(input_text.strip(), on_chunk=on_chunk, on_thinking=on_thinking)
199
270
  self.history = agent.history
200
271
  self.current_chat = self.history
201
272
  self.saved_chats = self.chat_store.list_chats()
273
+ if response and not buffer:
274
+ buffer = response
275
+ if not buffer:
276
+ raise RuntimeError("The assistant returned an empty response")
277
+ self._render(stdscr, "", "")
202
278
  return buffer
203
279
 
204
280
  def _handle_mouse_click(self, x: int, y: int) -> bool:
205
281
  sidebar_w = min(26, max(20, curses.COLS // 4))
206
282
  sidebar_x = 1
207
- sidebar_y = 1
208
- sidebar_h = max(8, curses.LINES - 4)
283
+ sidebar_y = 6
284
+ sidebar_h = max(8, curses.LINES - 9)
209
285
 
210
286
  if sidebar_x <= x < sidebar_x + sidebar_w and sidebar_y <= y < sidebar_y + sidebar_h:
211
287
  chat_files = self.chat_store.list_chats()[-8:]
@@ -224,12 +300,33 @@ class CobaltTUI:
224
300
  height, width = curses.LINES, curses.COLS
225
301
  sidebar_w = min(26, max(20, width // 4))
226
302
  main_x = 3 + sidebar_w
227
- main_y = 1
303
+ main_y = 6
228
304
  main_w = max(20, width - main_x - 2)
229
- main_h = max(8, height - 4)
305
+ main_h = max(8, height - 9)
230
306
  box_x, box_y, box_width, box_height, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
231
307
  return box_x <= x < box_x + box_width and box_y <= y < box_y + box_height
232
308
 
309
+ def _handle_thinking_click(self, x: int, y: int) -> bool:
310
+ height, width = curses.LINES, curses.COLS
311
+ sidebar_w = min(26, max(20, width // 4))
312
+ main_x, main_y = 3 + sidebar_w, 6
313
+ main_w, main_h = max(20, width - main_x - 2), max(8, height - 9)
314
+ _, box_y, _, _, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, "")
315
+ rows: list[tuple[str, int]] = []
316
+ for index, message in enumerate(self.history.latest(18)):
317
+ rows.extend(self._format_message_lines(message, main_w - 8, index))
318
+ for row_index, (line, _) in enumerate(rows):
319
+ if main_y + 1 + row_index >= box_y:
320
+ break
321
+ if line.startswith(("[-] thinking", "[+] thinking")) and main_y + 1 + row_index == y:
322
+ 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)
323
+ if message_index in self.thinking_expanded:
324
+ self.thinking_expanded.remove(message_index)
325
+ else:
326
+ self.thinking_expanded.add(message_index)
327
+ return True
328
+ return False
329
+
233
330
  def run(self) -> int:
234
331
  def _main(stdscr):
235
332
  curses.curs_set(0)
@@ -251,11 +348,24 @@ class CobaltTUI:
251
348
 
252
349
  if ch == curses.KEY_MOUSE:
253
350
  _, x, y, _, button_state = curses.getmouse()
351
+ button = self._handle_button_click(x, y, stdscr.getmaxyx()[1]) if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) else None
352
+ if button == "new":
353
+ self._start_new_chat()
354
+ input_text = ""
355
+ self.input_focused = True
356
+ continue
357
+ if button == "delete":
358
+ self._delete_selected_chat()
359
+ input_text = ""
360
+ self.input_focused = False
361
+ continue
254
362
  if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._click_is_in_input(x, y, input_text):
255
363
  self.input_focused = True
256
364
  continue
365
+ if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._handle_thinking_click(x, y):
366
+ continue
257
367
  if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._handle_mouse_click(x, y):
258
- status = "Chat selected"
368
+ status = ""
259
369
  input_text = ""
260
370
  self.input_focused = False
261
371
  continue
@@ -265,33 +375,6 @@ class CobaltTUI:
265
375
  if ch == 27:
266
376
  self.input_focused = False
267
377
  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 = "New chat started"
277
- 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 = "Chat deleted"
294
- continue
295
378
  if ch in (10, 13, curses.KEY_ENTER):
296
379
  if input_text.strip():
297
380
  try:
@@ -299,7 +382,7 @@ class CobaltTUI:
299
382
  self.history.title = self._suggest_chat_title(input_text.strip())
300
383
  self.history.save()
301
384
  self._stream_response(stdscr, input_text)
302
- status = "Response received"
385
+ status = ""
303
386
  except (OSError, RuntimeError, ValueError) as exc:
304
387
  self.history.add("assistant", f"Error: {exc}")
305
388
  status = ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cobalt-cli-linux
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -77,7 +77,9 @@ 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.
81
83
 
82
84
  You can also pass arguments directly:
83
85
 
@@ -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
@@ -6,6 +6,7 @@ from cobalt_cli_linux.config import Settings
6
6
  from cobalt_cli_linux.executor import ShellExecutor
7
7
  from cobalt_cli_linux.groq_client import GroqClient
8
8
  from cobalt_cli_linux.history import ChatStore, ConversationHistory
9
+ from cobalt_cli_linux.profiles import ProfileStore
9
10
  from cobalt_cli_linux.tui import CobaltTUI
10
11
 
11
12
 
@@ -46,22 +47,15 @@ def test_chat_store_delete_chat(tmp_path: Path) -> None:
46
47
  assert store.list_chats()[0].name == second.path.name
47
48
 
48
49
 
49
- def test_tui_shortcuts_ignore_typed_letters() -> None:
50
+ def test_tui_buttons_replace_chat_shortcuts() -> None:
50
51
  tui = CobaltTUI(settings=Settings())
51
52
 
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
53
+ buttons = tui._button_regions(120)
59
54
 
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
55
+ assert buttons["new"][2] == len("[ New chat ]")
56
+ assert buttons["delete"][2] == len("[ Delete chat ]")
57
+ assert tui._handle_button_click(buttons["new"][0], buttons["new"][1], 120) == "new"
58
+ assert tui._handle_button_click(buttons["delete"][0], buttons["delete"][1], 120) == "delete"
65
59
 
66
60
 
67
61
  def test_no_prompt_launches_tui(monkeypatch) -> None:
@@ -126,6 +120,26 @@ def test_stream_error_reads_response_before_inspecting_body(monkeypatch) -> None
126
120
  raise AssertionError("Expected streamed API error")
127
121
 
128
122
 
123
+ def test_profiles_create_delete_and_guest_limit(tmp_path: Path) -> None:
124
+ store = ProfileStore(tmp_path / "profiles.json")
125
+
126
+ assert store.allow_prompt() is True
127
+ assert store.allow_prompt() is True
128
+ assert store.allow_prompt() is True
129
+ assert store.allow_prompt() is False
130
+ assert store.create("personal") == "personal"
131
+ assert store.allow_prompt() is True
132
+ assert store.delete("personal") is True
133
+ assert store.active == "guest"
134
+
135
+
136
+ def test_tui_empty_chat_does_not_load_prompt_art_as_body_content(tmp_path: Path) -> None:
137
+ tui = CobaltTUI(settings=Settings())
138
+
139
+ assert tui.ascii_lines
140
+ assert tui.ascii_lines[0].strip().startswith("____")
141
+
142
+
129
143
  def test_write_file_tool_creates_and_verifies_file(tmp_path: Path) -> None:
130
144
  agent = CobaltAgent(settings=Settings(), history=ConversationHistory(tmp_path / "history.json"))
131
145
  target = tmp_path / "test.txt"