neuroncli 2.1.0__py3-none-any.whl

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.
neuroncli/provider.py ADDED
@@ -0,0 +1,45 @@
1
+ """NeuronCLI — Provider abstraction. OpenRouter primary, Ollama fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Generator, Protocol
6
+
7
+ from .config import AgentConfig
8
+
9
+
10
+ class ChatMessage:
11
+ """A single message in the conversation."""
12
+ __slots__ = ("role", "content")
13
+
14
+ def __init__(self, role: str, content: str):
15
+ self.role = role
16
+ self.content = content
17
+
18
+ def to_dict(self) -> dict[str, str]:
19
+ return {"role": self.role, "content": self.content}
20
+
21
+
22
+ class LLMProvider(Protocol):
23
+ """Protocol for LLM backends."""
24
+ def health_check(self) -> bool: ...
25
+ def list_models(self) -> list[str]: ...
26
+ def chat(self, messages: list, model: str | None = None) -> str: ...
27
+ def chat_stream(self, messages: list, model: str | None = None) -> Generator[str, None, None]: ...
28
+
29
+
30
+ class ProviderConnectionError(Exception):
31
+ """Raised when the active provider cannot be reached."""
32
+ pass
33
+
34
+
35
+ def create_provider(config: AgentConfig):
36
+ """
37
+ Factory: returns the correct LLM client based on config.provider.
38
+ Supports 'openrouter' (primary) and 'ollama' (fallback).
39
+ """
40
+ if config.provider == "ollama":
41
+ from .ollama_client import OllamaClient
42
+ return OllamaClient(config)
43
+ else:
44
+ from .openrouter_client import OpenRouterClient
45
+ return OpenRouterClient(config)
neuroncli/session.py ADDED
@@ -0,0 +1,88 @@
1
+ """NeuronCLI — Session persistence. Save/resume conversation history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ SESSIONS_DIR = Path.home() / ".neuroncli" / "sessions"
14
+
15
+
16
+ def _session_id(working_dir: str) -> str:
17
+ """Generate a stable session ID from the working directory."""
18
+ return hashlib.md5(working_dir.encode()).hexdigest()[:12]
19
+
20
+
21
+ def _session_path(working_dir: str) -> Path:
22
+ return SESSIONS_DIR / f"{_session_id(working_dir)}.json"
23
+
24
+
25
+ def save_session(working_dir: str, messages: list[dict], metadata: dict | None = None) -> Path:
26
+ """Save the current conversation to disk."""
27
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
28
+ path = _session_path(working_dir)
29
+
30
+ data = {
31
+ "working_dir": working_dir,
32
+ "saved_at": time.time(),
33
+ "saved_at_human": time.strftime("%Y-%m-%d %H:%M:%S"),
34
+ "message_count": len(messages),
35
+ "messages": messages,
36
+ "metadata": metadata or {},
37
+ }
38
+
39
+ with open(path, "w", encoding="utf-8") as f:
40
+ json.dump(data, f, indent=2, ensure_ascii=False)
41
+
42
+ return path
43
+
44
+
45
+ def load_session(working_dir: str) -> list[dict] | None:
46
+ """Load previous session for this directory. Returns messages or None."""
47
+ path = _session_path(working_dir)
48
+ if not path.exists():
49
+ return None
50
+
51
+ try:
52
+ with open(path, "r", encoding="utf-8") as f:
53
+ data = json.load(f)
54
+ return data.get("messages")
55
+ except (json.JSONDecodeError, OSError):
56
+ return None
57
+
58
+
59
+ def list_sessions() -> list[dict[str, Any]]:
60
+ """List all saved sessions with metadata."""
61
+ if not SESSIONS_DIR.exists():
62
+ return []
63
+
64
+ sessions = []
65
+ for f in SESSIONS_DIR.glob("*.json"):
66
+ try:
67
+ with open(f, "r", encoding="utf-8") as fh:
68
+ data = json.load(fh)
69
+ sessions.append({
70
+ "id": f.stem,
71
+ "working_dir": data.get("working_dir", "?"),
72
+ "saved_at": data.get("saved_at_human", "?"),
73
+ "messages": data.get("message_count", 0),
74
+ })
75
+ except (json.JSONDecodeError, OSError):
76
+ continue
77
+
78
+ sessions.sort(key=lambda s: s.get("saved_at", ""), reverse=True)
79
+ return sessions
80
+
81
+
82
+ def delete_session(working_dir: str) -> bool:
83
+ """Delete the session for a directory."""
84
+ path = _session_path(working_dir)
85
+ if path.exists():
86
+ path.unlink()
87
+ return True
88
+ return False
neuroncli/tools.py ADDED
@@ -0,0 +1,413 @@
1
+ """NeuronCLI — Tool implementations and registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+ import re
8
+ import subprocess
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Callable
12
+
13
+ from .config import AgentConfig
14
+
15
+
16
+ # ── Tool Registry ─────────────────────────────────────────────────
17
+
18
+ @dataclass
19
+ class ToolSpec:
20
+ """Metadata for a single tool."""
21
+ name: str
22
+ description: str
23
+ parameters: dict[str, str] # param_name -> description
24
+ required: list[str]
25
+ function: Callable[..., str]
26
+
27
+ def signature_for_prompt(self) -> str:
28
+ """Generate a description for the system prompt."""
29
+ params = ", ".join(
30
+ f'{name}: {desc}' for name, desc in self.parameters.items()
31
+ )
32
+ req = ", ".join(self.required)
33
+ return (
34
+ f"### {self.name}\n"
35
+ f"Description: {self.description}\n"
36
+ f"Parameters: {params}\n"
37
+ f"Required: {req}"
38
+ )
39
+
40
+
41
+ class ToolRegistry:
42
+ """Central registry of all available tools."""
43
+
44
+ def __init__(self):
45
+ self._tools: dict[str, ToolSpec] = {}
46
+
47
+ def register(
48
+ self,
49
+ name: str,
50
+ description: str,
51
+ parameters: dict[str, str],
52
+ required: list[str] | None = None,
53
+ ) -> Callable:
54
+ """Decorator to register a tool function."""
55
+ def decorator(func: Callable[..., str]) -> Callable[..., str]:
56
+ self._tools[name] = ToolSpec(
57
+ name=name,
58
+ description=description,
59
+ parameters=parameters,
60
+ required=required or list(parameters.keys()),
61
+ function=func,
62
+ )
63
+ return func
64
+ return decorator
65
+
66
+ def get(self, name: str) -> ToolSpec | None:
67
+ return self._tools.get(name)
68
+
69
+ def all_tools(self) -> list[ToolSpec]:
70
+ return list(self._tools.values())
71
+
72
+ def tool_names(self) -> list[str]:
73
+ return list(self._tools.keys())
74
+
75
+ def execute(self, name: str, args: dict, config: AgentConfig) -> str:
76
+ """Execute a tool by name with given arguments."""
77
+ spec = self._tools.get(name)
78
+ if spec is None:
79
+ return f"Error: Unknown tool '{name}'. Available: {', '.join(self.tool_names())}"
80
+
81
+ # Validate required params
82
+ for req in spec.required:
83
+ if req not in args:
84
+ return f"Error: Missing required parameter '{req}' for tool '{name}'"
85
+
86
+ try:
87
+ return spec.function(config=config, **args)
88
+ except Exception as exc:
89
+ return f"Error executing {name}: {type(exc).__name__}: {exc}"
90
+
91
+ def generate_prompt_section(self) -> str:
92
+ """Generate tool descriptions for the system prompt."""
93
+ sections = []
94
+ for spec in self._tools.values():
95
+ sections.append(spec.signature_for_prompt())
96
+ return "\n\n".join(sections)
97
+
98
+
99
+ # ── Global registry instance ─────────────────────────────────────
100
+
101
+ registry = ToolRegistry()
102
+
103
+
104
+ # ── Tool Implementations ─────────────────────────────────────────
105
+
106
+ @registry.register(
107
+ name="read_file",
108
+ description="Read the contents of a file. Returns the file content with line numbers.",
109
+ parameters={
110
+ "path": "Path to the file (relative to working directory or absolute)",
111
+ },
112
+ required=["path"],
113
+ )
114
+ def read_file(config: AgentConfig, path: str) -> str:
115
+ resolved = config.resolve_path(path)
116
+ if not resolved.exists():
117
+ return f"Error: File not found: {resolved}"
118
+ if not resolved.is_file():
119
+ return f"Error: Not a file: {resolved}"
120
+ try:
121
+ content = resolved.read_text(encoding="utf-8", errors="replace")
122
+ lines = content.splitlines()
123
+ # Add line numbers
124
+ numbered = "\n".join(f"{i+1:4d} | {line}" for i, line in enumerate(lines))
125
+ return f"File: {path} ({len(lines)} lines, {resolved.stat().st_size} bytes)\n{'─' * 60}\n{numbered}"
126
+ except Exception as exc:
127
+ return f"Error reading {path}: {exc}"
128
+
129
+
130
+ @registry.register(
131
+ name="write_file",
132
+ description="Write content to a file. Creates the file (and parent directories) if it doesn't exist, or overwrites it.",
133
+ parameters={
134
+ "path": "Path to the file",
135
+ "content": "The full content to write to the file",
136
+ },
137
+ required=["path", "content"],
138
+ )
139
+ def write_file(config: AgentConfig, path: str, content: str) -> str:
140
+ resolved = config.resolve_path(path)
141
+ try:
142
+ resolved.parent.mkdir(parents=True, exist_ok=True)
143
+ resolved.write_text(content, encoding="utf-8")
144
+ line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
145
+ return f"[OK] Written {len(content)} bytes ({line_count} lines) to {path}"
146
+ except Exception as exc:
147
+ return f"Error writing {path}: {exc}"
148
+
149
+
150
+ @registry.register(
151
+ name="edit_file",
152
+ description="Edit a file by replacing a specific text block with new content. Use this for surgical edits instead of rewriting the entire file.",
153
+ parameters={
154
+ "path": "Path to the file",
155
+ "old_text": "The exact text to find and replace (must match precisely including whitespace)",
156
+ "new_text": "The replacement text",
157
+ },
158
+ required=["path", "old_text", "new_text"],
159
+ )
160
+ def edit_file(config: AgentConfig, path: str, old_text: str, new_text: str) -> str:
161
+ resolved = config.resolve_path(path)
162
+ if not resolved.exists():
163
+ return f"Error: File not found: {resolved}"
164
+ try:
165
+ content = resolved.read_text(encoding="utf-8")
166
+ if old_text not in content:
167
+ # Try with normalized whitespace
168
+ normalized_content = content.replace("\r\n", "\n")
169
+ normalized_old = old_text.replace("\r\n", "\n")
170
+ if normalized_old in normalized_content:
171
+ content = normalized_content
172
+ old_text = normalized_old
173
+ else:
174
+ # Show a snippet of the file for debugging
175
+ preview = content[:500]
176
+ return (
177
+ f"Error: Could not find the exact text to replace in {path}.\n"
178
+ f"File preview (first 500 chars):\n{preview}"
179
+ )
180
+ count = content.count(old_text)
181
+ new_content = content.replace(old_text, new_text, 1)
182
+ resolved.write_text(new_content, encoding="utf-8")
183
+ return f"[OK] Edited {path}: replaced {count} occurrence(s) ({len(old_text)} chars -> {len(new_text)} chars)"
184
+ except Exception as exc:
185
+ return f"Error editing {path}: {exc}"
186
+
187
+
188
+ @registry.register(
189
+ name="list_directory",
190
+ description="List files and subdirectories in a directory. Shows a tree view with file sizes.",
191
+ parameters={
192
+ "path": "Directory path (default: current working directory)",
193
+ "recursive": "If 'true', show full recursive tree (default: 'false', shows 2 levels)",
194
+ },
195
+ required=["path"],
196
+ )
197
+ def list_directory(config: AgentConfig, path: str = ".", recursive: str = "false") -> str:
198
+ resolved = config.resolve_path(path)
199
+ if not resolved.exists():
200
+ return f"Error: Directory not found: {resolved}"
201
+ if not resolved.is_dir():
202
+ return f"Error: Not a directory: {resolved}"
203
+
204
+ is_recursive = recursive.lower() in ("true", "yes", "1")
205
+ max_depth = 100 if is_recursive else 2
206
+
207
+ lines: list[str] = [f"[DIR] {resolved.name}/"]
208
+ _tree_walk(resolved, lines, prefix="", depth=0, max_depth=max_depth)
209
+
210
+ if len(lines) > 200:
211
+ lines = lines[:200]
212
+ lines.append(f"... (truncated, {len(lines)}+ entries)")
213
+
214
+ return "\n".join(lines)
215
+
216
+
217
+ def _tree_walk(directory: Path, lines: list[str], prefix: str, depth: int, max_depth: int):
218
+ """Recursively build a tree view. Robust against Windows permission errors."""
219
+ if depth >= max_depth:
220
+ return
221
+
222
+ SKIP = {".git", "__pycache__", "node_modules", ".venv", "venv", ".mypy_cache", ".pytest_cache", "dist", "build", ".tox", ".eggs"}
223
+
224
+ try:
225
+ entries = sorted(directory.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
226
+ except (PermissionError, OSError):
227
+ lines.append(f"{prefix}└── [access denied]")
228
+ return
229
+
230
+ entries = [e for e in entries if e.name not in SKIP and not e.name.startswith('.')]
231
+ total = len(entries)
232
+
233
+ for idx, entry in enumerate(entries):
234
+ is_last = idx == total - 1
235
+ connector = "└── " if is_last else "├── "
236
+ extension = " " if is_last else "│ "
237
+
238
+ try:
239
+ if entry.is_dir():
240
+ try:
241
+ child_count = sum(1 for _ in entry.iterdir()) if depth < max_depth - 1 else 0
242
+ except (PermissionError, OSError):
243
+ child_count = 0
244
+ lines.append(f"{prefix}{connector}[DIR] {entry.name}/ ({child_count} items)")
245
+ _tree_walk(entry, lines, prefix + extension, depth + 1, max_depth)
246
+ else:
247
+ try:
248
+ size = entry.stat().st_size
249
+ size_str = _format_size(size)
250
+ except (PermissionError, OSError):
251
+ size_str = "?"
252
+ lines.append(f"{prefix}{connector}{entry.name} ({size_str})")
253
+ except (PermissionError, OSError):
254
+ lines.append(f"{prefix}{connector}{entry.name} [access denied]")
255
+
256
+
257
+ def _format_size(size: int) -> str:
258
+ if size < 1024:
259
+ return f"{size} B"
260
+ elif size < 1024 * 1024:
261
+ return f"{size / 1024:.1f} KB"
262
+ else:
263
+ return f"{size / (1024 * 1024):.1f} MB"
264
+
265
+
266
+ @registry.register(
267
+ name="run_command",
268
+ description="Run a shell command and return its stdout and stderr. Use for build, test, git, or any terminal command.",
269
+ parameters={
270
+ "command": "The shell command to execute",
271
+ },
272
+ required=["command"],
273
+ )
274
+ def run_command(config: AgentConfig, command: str) -> str:
275
+ # Safety check
276
+ if config.is_dangerous_command(command):
277
+ return (
278
+ f"[BLOCKED] The command '{command}' matches a dangerous pattern.\n"
279
+ f"If you're sure, the user can re-run with confirm_dangerous=False."
280
+ )
281
+
282
+ try:
283
+ result = subprocess.run(
284
+ command,
285
+ shell=True,
286
+ capture_output=True,
287
+ text=True,
288
+ cwd=config.working_dir,
289
+ timeout=60,
290
+ )
291
+ parts = []
292
+ if result.stdout:
293
+ parts.append(f"STDOUT:\n{result.stdout.strip()}")
294
+ if result.stderr:
295
+ parts.append(f"STDERR:\n{result.stderr.strip()}")
296
+ parts.append(f"Exit code: {result.returncode}")
297
+
298
+ output = "\n".join(parts) if parts else "Command completed with no output."
299
+
300
+ # Truncate very long output
301
+ if len(output) > 5000:
302
+ output = output[:5000] + f"\n... (truncated, {len(output)} total chars)"
303
+
304
+ return output
305
+
306
+ except subprocess.TimeoutExpired:
307
+ return f"Error: Command timed out after 60 seconds: {command}"
308
+ except Exception as exc:
309
+ return f"Error running command: {exc}"
310
+
311
+
312
+ @registry.register(
313
+ name="search_in_files",
314
+ description="Search for a text pattern across files in a directory. Like grep. Returns matching lines with file paths and line numbers.",
315
+ parameters={
316
+ "query": "Text or regex pattern to search for",
317
+ "directory": "Directory to search in (default: working directory)",
318
+ "pattern": "File glob pattern to filter files (default: '*.*')",
319
+ },
320
+ required=["query"],
321
+ )
322
+ def search_in_files(
323
+ config: AgentConfig,
324
+ query: str,
325
+ directory: str = ".",
326
+ pattern: str = "*.*",
327
+ ) -> str:
328
+ resolved = config.resolve_path(directory)
329
+ if not resolved.exists():
330
+ return f"Error: Directory not found: {resolved}"
331
+
332
+ SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build"}
333
+ matches: list[str] = []
334
+ files_searched = 0
335
+
336
+ try:
337
+ compiled = re.compile(query, re.IGNORECASE)
338
+ except re.error:
339
+ compiled = re.compile(re.escape(query), re.IGNORECASE)
340
+
341
+ for root_path, dirs, files in os.walk(resolved):
342
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
343
+ for filename in files:
344
+ if not fnmatch.fnmatch(filename, pattern):
345
+ continue
346
+ filepath = Path(root_path) / filename
347
+ files_searched += 1
348
+ try:
349
+ text = filepath.read_text(encoding="utf-8", errors="replace")
350
+ for line_no, line in enumerate(text.splitlines(), 1):
351
+ if compiled.search(line):
352
+ rel = filepath.relative_to(resolved)
353
+ matches.append(f" {rel}:{line_no} {line.strip()}")
354
+ if len(matches) >= 50:
355
+ break
356
+ except (OSError, UnicodeDecodeError):
357
+ continue
358
+ if len(matches) >= 50:
359
+ break
360
+
361
+ if not matches:
362
+ return f"No matches for '{query}' in {files_searched} files."
363
+
364
+ header = f"Found {len(matches)} match(es) for '{query}' in {files_searched} files:\n"
365
+ return header + "\n".join(matches)
366
+
367
+
368
+ @registry.register(
369
+ name="get_project_structure",
370
+ description="Get a comprehensive overview of the project: directory tree, key files, and README content if available.",
371
+ parameters={
372
+ "path": "Root path of the project (default: working directory)",
373
+ },
374
+ required=[],
375
+ )
376
+ def get_project_structure(config: AgentConfig, path: str = ".") -> str:
377
+ resolved = config.resolve_path(path)
378
+ if not resolved.exists():
379
+ return f"Error: Path not found: {resolved}"
380
+
381
+ parts: list[str] = ["# Project Structure\n"]
382
+
383
+ # Directory tree (2 levels)
384
+ tree_lines: list[str] = [f"[DIR] {resolved.name}/"]
385
+ _tree_walk(resolved, tree_lines, prefix="", depth=0, max_depth=2)
386
+ parts.append("\n".join(tree_lines))
387
+
388
+ # Count stats
389
+ py_files = list(resolved.rglob("*.py"))
390
+ js_files = list(resolved.rglob("*.js")) + list(resolved.rglob("*.ts"))
391
+ rs_files = list(resolved.rglob("*.rs"))
392
+
393
+ parts.append(f"\nStats: {len(py_files)} .py | {len(js_files)} .js/.ts | {len(rs_files)} .rs")
394
+
395
+ # Check for key files
396
+ key_files = ["README.md", "setup.py", "pyproject.toml", "package.json",
397
+ "Cargo.toml", "requirements.txt", "Makefile", ".gitignore"]
398
+ found = [f for f in key_files if (resolved / f).exists()]
399
+ if found:
400
+ parts.append(f"Key files: {', '.join(found)}")
401
+
402
+ # Read README if available
403
+ readme = resolved / "README.md"
404
+ if readme.exists():
405
+ try:
406
+ content = readme.read_text(encoding="utf-8", errors="replace")
407
+ if len(content) > 1000:
408
+ content = content[:1000] + "\n... (truncated)"
409
+ parts.append(f"\n## README.md\n{content}")
410
+ except OSError:
411
+ pass
412
+
413
+ return "\n".join(parts)
neuroncli/ui.py ADDED
@@ -0,0 +1,138 @@
1
+ """NeuronCLI — Terminal UI components v2.0.
2
+ Brand symbols, NO emojis, consistent colors, proper table rendering."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import getpass
7
+ import os
8
+
9
+ from .config import VERSION
10
+
11
+
12
+ # ── True Color ANSI ───────────────────────────────────────────────
13
+
14
+ def _rgb(r: int, g: int, b: int) -> str:
15
+ return f"\033[38;2;{r};{g};{b}m"
16
+
17
+ def _bg_rgb(r: int, g: int, b: int) -> str:
18
+ return f"\033[48;2;{r};{g};{b}m"
19
+
20
+ RST = "\033[0m"
21
+ BOLD = "\033[1m"
22
+ DIM = "\033[2m"
23
+
24
+ # ── Brand Colors (sampled from the Neuron DNA watercolor logo) ────
25
+ _BLUE = _rgb(65, 105, 195)
26
+ _INDIGO = _rgb(95, 55, 140)
27
+ _RED = _rgb(200, 50, 40)
28
+ _ORANGE = _rgb(240, 160, 40) # PRIMARY brand color
29
+ _GREEN = _rgb(45, 140, 60)
30
+ _CYAN = _rgb(80, 170, 210)
31
+ _GRAY = _rgb(100, 100, 100)
32
+ _YELLOW = _rgb(230, 190, 50)
33
+ _WHITE = _rgb(220, 220, 220)
34
+
35
+ # ── Brand Symbols (NO emojis — Claude uses plain Unicode chars) ───
36
+ # Claude uses: * (asterisk) colored orange for their logo indicator
37
+ # We use the same approach with our brand color
38
+
39
+ SYM_LOGO = f"{_ORANGE}{BOLD}*{RST}" # Brand indicator (like Claude's flower)
40
+ SYM_BULLET = f"{_ORANGE}{BOLD}*{RST}" # Action bullet
41
+ SYM_THINK = f"{_ORANGE}+{RST}" # Thinking/working spinner
42
+ SYM_OK = f"{_GREEN}{BOLD}v{RST}" # Success checkmark
43
+ SYM_ERR = f"{_RED}{BOLD}x{RST}" # Error cross
44
+ SYM_WARN = f"{_YELLOW}{BOLD}!{RST}" # Warning
45
+ SYM_ARROW = f"{_GRAY}|_{RST}" # Sub-item tree connector
46
+ SYM_PROMPT = f"{_ORANGE}{BOLD}>{RST}" # Input prompt
47
+
48
+
49
+ # ── DNA Helix Logo (brand-colored ASCII art) ──────────────────────
50
+
51
+ def _build_logo() -> list[str]:
52
+ """Build the colored DNA helix logo matching the Neuron watercolor logo.
53
+ Blue (top) -> Indigo -> Red -> Orange -> Green (bottom)."""
54
+ return [
55
+ f" {_BLUE}* *{RST}",
56
+ f" {_BLUE}/ \\ / \\{RST}",
57
+ f" {_BLUE}/ {_INDIGO}/ \\/ \\{_BLUE} \\{RST}",
58
+ f" {_INDIGO}/ / /--\\ \\ \\{RST}",
59
+ f" {_INDIGO}| {_RED}/--------\\{_INDIGO} |{RST}",
60
+ f" {_RED}X----------X{RST}",
61
+ f" {_RED}| {_ORANGE}\\--------/{_RED} |{RST}",
62
+ f" {_ORANGE}\\ \\ \\--/ / /{RST}",
63
+ f" {_ORANGE}\\ {_GREEN}\\ /{_ORANGE} /{RST}",
64
+ f" {_GREEN}\\ / \\ /{RST}",
65
+ f" {_GREEN}* *{RST}",
66
+ ]
67
+
68
+
69
+ def _neuron_text() -> str:
70
+ """'Neuron' in gradient colors: N(blue) e(red) u(red) r(orange) o(orange) n(green)."""
71
+ return f"{_BLUE}{BOLD}N{_RED}e{_RED}u{_ORANGE}r{_ORANGE}o{_GREEN}n{RST}"
72
+
73
+
74
+ # ── Startup Screen ────────────────────────────────────────────────
75
+
76
+ def render_startup_screen(
77
+ working_dir: str,
78
+ provider: str,
79
+ model: str,
80
+ neuron_md_exists: bool = False,
81
+ ) -> str:
82
+ """Claude Code-style startup: logo left, tips right, in a box."""
83
+ try:
84
+ username = getpass.getuser().capitalize()
85
+ except Exception:
86
+ username = "Developer"
87
+
88
+ model_short = model.split("/")[-1] if "/" in model else model
89
+
90
+ logo = _build_logo()
91
+ width = 64
92
+ bc = _GRAY # border color
93
+
94
+ # Right panel
95
+ if neuron_md_exists:
96
+ tips = [
97
+ f"{_ORANGE}{BOLD}Tips{RST}",
98
+ f"{DIM}NEURON.md loaded for this project{RST}",
99
+ f"{DIM}Type a task to get started{RST}",
100
+ ]
101
+ else:
102
+ tips = [
103
+ f"{_ORANGE}{BOLD}Tips for getting started{RST}",
104
+ f"{DIM}Run {RST}/init{DIM} to create a NEURON.md{RST}",
105
+ f"{DIM}file with project context...{RST}",
106
+ ]
107
+
108
+ activity = [
109
+ "",
110
+ f"{_ORANGE}{BOLD}Recent activity{RST}",
111
+ f"{DIM}No recent activity{RST}",
112
+ ]
113
+ right_panel = tips + activity
114
+
115
+ lines = []
116
+ title = f" {_neuron_text()} {DIM}CLI v{VERSION}{RST} "
117
+ lines.append(f" {bc}+-{RST}{title}{bc}{'-' * (width - 22)}+{RST}")
118
+ lines.append(f" {bc}|{RST}")
119
+ lines.append(f" {bc}|{RST} {BOLD}Welcome, {username}!{RST}")
120
+ lines.append(f" {bc}|{RST}")
121
+
122
+ ri = 0
123
+ for logo_line in logo:
124
+ right = right_panel[ri] if ri < len(right_panel) else ""
125
+ ri += 1
126
+ lines.append(f" {bc}|{RST} {logo_line} {right}")
127
+
128
+ while ri < len(right_panel):
129
+ lines.append(f" {bc}|{RST}{'':30}{right_panel[ri]}")
130
+ ri += 1
131
+
132
+ lines.append(f" {bc}|{RST}")
133
+ lines.append(f" {bc}|{RST} {DIM}{model_short} . {provider}{RST}")
134
+ lines.append(f" {bc}|{RST} {DIM}{working_dir}{RST}")
135
+ lines.append(f" {bc}|{RST}")
136
+ lines.append(f" {bc}+{'-' * width}+{RST}")
137
+
138
+ return "\n".join(lines)