Compare commits
9 Commits
9273d14845
...
0886727437
| Author | SHA1 | Date | |
|---|---|---|---|
| 0886727437 | |||
| 638aecb561 | |||
| b878408f3e | |||
| 5b5c3098bb | |||
| 4e3da84578 | |||
| 2ad3df521d | |||
| 4496fce354 | |||
| 133bcbda57 | |||
| 7705008b9c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,3 +34,6 @@ htmlcov/
|
|||||||
|
|
||||||
# Worktrees
|
# Worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# SneakyCode local data
|
||||||
|
.sneakycode/
|
||||||
|
|||||||
122
README.md
122
README.md
@@ -26,31 +26,79 @@ pip install -e ".[dev]"
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Edit `config/config.yaml` to configure the agent. Key settings:
|
Edit `config/config.yaml` to configure the agent. The full configuration reference:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
llm:
|
llm:
|
||||||
model: "qwen3.5:latest" # Ollama model name
|
model: "qwen3.5:latest" # Ollama model name
|
||||||
endpoint: "http://localhost:11434" # Ollama endpoint
|
endpoint: "http://localhost:11434" # Ollama endpoint
|
||||||
max_retries: 3 # Retry attempts on transient errors
|
api_path: "/v1/chat/completions" # API endpoint path
|
||||||
retry_backoff_base: 1.0 # Exponential backoff base (seconds)
|
temperature: 0.1 # Sampling temperature
|
||||||
|
max_tokens: 4096 # Maximum tokens in LLM response
|
||||||
|
timeout: 120 # Request timeout in seconds
|
||||||
|
max_retries: 3 # Retry attempts on transient errors
|
||||||
|
retry_backoff_base: 1.0 # Exponential backoff base (seconds)
|
||||||
|
retry_backoff_max: 30.0 # Maximum backoff seconds
|
||||||
|
|
||||||
agent:
|
agent:
|
||||||
max_iterations: 25 # Max tool-call iterations per turn
|
max_iterations: 25 # Max tool-call iterations per turn
|
||||||
max_conversation_tokens: 32000 # Token budget for conversation
|
max_conversation_tokens: 32000 # Token budget for conversation
|
||||||
workspace_root: "." # Project directory for file operations
|
workspace_root: "." # Project directory for file operations
|
||||||
truncation_keep_recent: 10 # Messages preserved during truncation
|
truncation_keep_recent: 10 # Messages preserved during truncation
|
||||||
truncation_threshold: 0.85 # Budget fraction that triggers truncation
|
truncation_threshold: 0.85 # Budget fraction that triggers truncation
|
||||||
|
|
||||||
session:
|
session:
|
||||||
auto_save: true # Save session after each turn
|
session_dir: ".sneakycode/sessions" # Directory for session files
|
||||||
max_session_age_hours: 72 # Auto-cleanup old sessions
|
auto_save: true # Save session after each turn
|
||||||
offer_resume: true # Offer to resume on startup
|
max_session_age_hours: 72 # Auto-cleanup old sessions
|
||||||
|
offer_resume: true # Offer to resume on startup
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
auto_approve: [read_file, list_dir, grep_files, find_files, finish]
|
auto_approve: [read_file, list_dir, grep_files, find_files, finish]
|
||||||
prompt_user: [write_file, delete_file, run_command, str_replace, patch_apply, make_dir]
|
prompt_user: [write_file, delete_file, run_command, str_replace, patch_apply, make_dir]
|
||||||
deny: []
|
deny: []
|
||||||
|
|
||||||
|
tools:
|
||||||
|
shell:
|
||||||
|
allowed_commands: # Commands the LLM may run
|
||||||
|
- git
|
||||||
|
- python
|
||||||
|
- pip
|
||||||
|
- pytest
|
||||||
|
- ruff
|
||||||
|
- ls
|
||||||
|
- cat
|
||||||
|
- head
|
||||||
|
- tail
|
||||||
|
- wc
|
||||||
|
- diff
|
||||||
|
- grep
|
||||||
|
- find
|
||||||
|
- echo
|
||||||
|
denied_commands: # Blocked commands
|
||||||
|
- rm -rf /
|
||||||
|
- sudo
|
||||||
|
- curl
|
||||||
|
- wget
|
||||||
|
max_output_bytes: 65536 # Max captured output size (bytes)
|
||||||
|
filesystem:
|
||||||
|
max_file_size_bytes: 1048576 # 1 MB — max file size for read/write
|
||||||
|
binary_detection: true # Detect and reject binary files
|
||||||
|
|
||||||
|
display:
|
||||||
|
show_tool_calls: true # Show tool call details in output
|
||||||
|
show_token_usage: true # Show token usage stats
|
||||||
|
stream_output: true # Stream LLM output to terminal
|
||||||
|
|
||||||
|
skills:
|
||||||
|
enabled: true # Enable the skills system
|
||||||
|
directories: # Directories to scan for skill files
|
||||||
|
- ".sneakycode/skills"
|
||||||
|
|
||||||
|
debug:
|
||||||
|
enabled: false # Enable debug logging
|
||||||
|
log_dir: ".sneakycode/logs" # Debug log directory
|
||||||
|
max_files: 10 # Max debug log files to retain
|
||||||
```
|
```
|
||||||
|
|
||||||
Environment variable `SNEAKYCODE_CONFIG` can override the config file path.
|
Environment variable `SNEAKYCODE_CONFIG` can override the config file path.
|
||||||
@@ -58,9 +106,12 @@ Environment variable `SNEAKYCODE_CONFIG` can override the config file path.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start the interactive REPL
|
# Start the interactive TUI
|
||||||
sneakycode
|
sneakycode
|
||||||
|
|
||||||
|
# Open a specific project directory
|
||||||
|
sneakycode /path/to/project
|
||||||
|
|
||||||
# Or run directly
|
# Or run directly
|
||||||
python -m app.main
|
python -m app.main
|
||||||
|
|
||||||
@@ -68,25 +119,38 @@ python -m app.main
|
|||||||
sneakycode --config path/to/config.yaml --verbose --log-file sneakycode.log
|
sneakycode --config path/to/config.yaml --verbose --log-file sneakycode.log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CLI Options
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------------------------|--------------------------------------------------|
|
||||||
|
| `DIRECTORY` | Project directory to use as workspace root |
|
||||||
|
| `--config PATH` | Path to config YAML file (default: `config/config.yaml`) |
|
||||||
|
| `-v`, `--verbose` | Enable verbose (DEBUG) logging |
|
||||||
|
| `--log-file PATH` | Path to log file for persistent logging |
|
||||||
|
|
||||||
### REPL Commands
|
### REPL Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|------------|--------------------------------------|
|
|-------------------|----------------------------------------------------|
|
||||||
| `/quit` | Save session and exit |
|
| `/help` | Show available commands |
|
||||||
| `/history` | Show conversation history |
|
| `/quit` | Save session and exit (also `/exit`, `/bye`) |
|
||||||
| `/clear` | Clear conversation history |
|
| `/history` | Show conversation history |
|
||||||
| `/save` | Manually save session |
|
| `/clear` | Clear conversation history |
|
||||||
| `/session` | Show session info (messages, tokens) |
|
| `/save` | Manually save session |
|
||||||
|
| `/session` | Show session info (messages, tokens, start time) |
|
||||||
|
| `/models` | List available Ollama models |
|
||||||
|
| `/models <name>` | Switch to a different model |
|
||||||
|
| `/skills` | List available skills |
|
||||||
|
|
||||||
### Session Persistence
|
### Session Persistence
|
||||||
|
|
||||||
Sessions are automatically saved after each agent turn and on exit. On startup, SneakyCode offers to resume the most recent session for the current workspace.
|
Sessions are automatically saved after each agent turn and on exit. On startup, SneakyCode offers to resume the most recent session for the current workspace.
|
||||||
|
|
||||||
Session files are stored in `.sneakycode/sessions/` within the workspace root.
|
Session files are stored in `.sneakycode/sessions/` within the workspace root (configurable via `session.session_dir`).
|
||||||
|
|
||||||
## Available Tools
|
## Available Tools
|
||||||
|
|
||||||
SneakyCode provides 11 tools across 5 categories. See [docs/tools.md](docs/tools.md) for the full reference.
|
SneakyCode provides tools across 6 categories. See [docs/tools.md](docs/tools.md) for the full reference.
|
||||||
|
|
||||||
| Category | Tools | Permission |
|
| Category | Tools | Permission |
|
||||||
|------------|-------------------------------------------------|---------------|
|
|------------|-------------------------------------------------|---------------|
|
||||||
@@ -96,6 +160,17 @@ SneakyCode provides 11 tools across 5 categories. See [docs/tools.md](docs/tools
|
|||||||
| Edit | `str_replace`, `patch_apply` | User confirm |
|
| Edit | `str_replace`, `patch_apply` | User confirm |
|
||||||
| Shell | `run_command` | User confirm |
|
| Shell | `run_command` | User confirm |
|
||||||
| Control | `finish` | Auto-approved |
|
| Control | `finish` | Auto-approved |
|
||||||
|
| Skills | `load_skill` | Auto-approved |
|
||||||
|
|
||||||
|
The `load_skill` tool is available when `skills.enabled` is `true` in the config. It allows the LLM to load skill instructions from the configured skill directories.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
SneakyCode includes a skills system that lets you provide reusable instruction sets to the LLM. Skills are markdown files placed in `.sneakycode/skills/` (or any directory listed in `skills.directories`).
|
||||||
|
|
||||||
|
Skills are auto-discovered on startup. The LLM can load them via the `load_skill` tool, and you can list available skills with the `/skills` command.
|
||||||
|
|
||||||
|
To create a skill, add a `.md` file to your skills directory with a descriptive filename (e.g., `refactoring.md`). The file content is injected into the conversation when the skill is loaded.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -121,6 +196,7 @@ app/
|
|||||||
├── models/ # Pydantic config and message schemas
|
├── models/ # Pydantic config and message schemas
|
||||||
├── services/ # LLM client, streaming, permissions, session persistence
|
├── services/ # LLM client, streaming, permissions, session persistence
|
||||||
├── tools/ # Tool implementations (one file per group)
|
├── tools/ # Tool implementations (one file per group)
|
||||||
|
├── ui/ # Textual TUI application and widgets
|
||||||
└── utils/ # Logging, display, file helpers, token counter
|
└── utils/ # Logging, display, file helpers, token counter
|
||||||
config/
|
config/
|
||||||
└── config.yaml # Application configuration
|
└── config.yaml # Application configuration
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import time
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from app.agent.context import SessionContext
|
from app.agent.context import SessionContext
|
||||||
from app.models.config import AppConfig
|
from app.models.config import AgentMode, AppConfig
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.tool_call import ToolCall, ToolResult, ToolResultStatus
|
from app.models.tool_call import ToolCall, ToolResult, ToolResultStatus
|
||||||
from app.services.llm import LLMClient, LLMConnectionError, LLMError, LLMStreamError
|
from app.services.llm import LLMClient, LLMConnectionError, LLMError, LLMStreamError
|
||||||
@@ -59,6 +59,12 @@ class AgentLoop:
|
|||||||
self._skills = skills_manager
|
self._skills = skills_manager
|
||||||
self._skill_runner = skill_runner
|
self._skill_runner = skill_runner
|
||||||
self._tools_schema = registry.get_openai_tools_schema()
|
self._tools_schema = registry.get_openai_tools_schema()
|
||||||
|
if self._permissions.mode == AgentMode.PLAN:
|
||||||
|
read_only = PermissionsService.READ_ONLY_TOOLS
|
||||||
|
self._tools_schema = [
|
||||||
|
t for t in self._tools_schema
|
||||||
|
if t["function"]["name"] in read_only
|
||||||
|
]
|
||||||
self._system_prompt = self._build_system_prompt()
|
self._system_prompt = self._build_system_prompt()
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
|
|
||||||
@@ -89,29 +95,36 @@ class AgentLoop:
|
|||||||
f"\n\nCurrently active skill: {self._skill_runner.active_skill_name}. "
|
f"\n\nCurrently active skill: {self._skill_runner.active_skill_name}. "
|
||||||
"When the skill's objective is complete, call the `finish_skill` tool."
|
"When the skill's objective is complete, call the `finish_skill` tool."
|
||||||
)
|
)
|
||||||
|
if self._permissions.mode == AgentMode.PLAN:
|
||||||
|
prompt += (
|
||||||
|
"\n\nYou are in PLAN mode. You may only use read-only tools: "
|
||||||
|
"read_file, list_dir, grep_files, find_files, finish. "
|
||||||
|
"Do NOT attempt to write files, edit code, or run commands. "
|
||||||
|
"Instead, describe what changes you would make, which files "
|
||||||
|
"you would modify, and provide the reasoning for each change."
|
||||||
|
)
|
||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
def _get_messages_with_system_prompt(self) -> list[Message]:
|
def _get_messages_with_system_prompt(self) -> list[Message]:
|
||||||
"""Prepend the system prompt to conversation history.
|
"""Prepend the system prompt to conversation history.
|
||||||
|
|
||||||
When thinking is disabled, injects a /no_think tag into the last user
|
When thinking is disabled, appends a system-level /no_think directive
|
||||||
message so Qwen 3.x (and similar) chat templates see it regardless of
|
after the last user message so Qwen 3.x (and similar) chat templates
|
||||||
where tool-result messages fall in the history.
|
see it, without polluting the user's actual message content.
|
||||||
"""
|
"""
|
||||||
system_msg = Message(role="system", content=self._system_prompt)
|
system_msg = Message(role="system", content=self._system_prompt)
|
||||||
history = self._ctx.get_history()
|
history = self._ctx.get_history()
|
||||||
|
|
||||||
if not self._config.llm.thinking and history:
|
if not self._config.llm.thinking and history:
|
||||||
history = list(history)
|
history = list(history)
|
||||||
|
# Find last user message and insert a system hint after it
|
||||||
for i in range(len(history) - 1, -1, -1):
|
for i in range(len(history) - 1, -1, -1):
|
||||||
if history[i].role == "user":
|
if history[i].role == "user":
|
||||||
original = history[i]
|
no_think_msg = Message(
|
||||||
history[i] = Message(
|
role="system",
|
||||||
role="user",
|
content="/no_think",
|
||||||
content=(original.content or "") + " /no_think",
|
|
||||||
tool_call_id=original.tool_call_id,
|
|
||||||
name=original.name,
|
|
||||||
)
|
)
|
||||||
|
history.insert(i + 1, no_think_msg)
|
||||||
break
|
break
|
||||||
|
|
||||||
return [system_msg] + history
|
return [system_msg] + history
|
||||||
@@ -217,10 +230,12 @@ class AgentLoop:
|
|||||||
# Successful response — reset streak
|
# Successful response — reset streak
|
||||||
reasoning_only_streak = 0
|
reasoning_only_streak = 0
|
||||||
|
|
||||||
|
# Display any assistant text content (even if tool calls follow)
|
||||||
|
if self._display and assistant_msg.content:
|
||||||
|
self._display.write_assistant_message(assistant_msg.content)
|
||||||
|
|
||||||
# No tool calls → task complete (plain text response)
|
# No tool calls → task complete (plain text response)
|
||||||
if not assistant_msg.tool_calls:
|
if not assistant_msg.tool_calls:
|
||||||
if self._display and assistant_msg.content:
|
|
||||||
self._display.write_assistant_message(assistant_msg.content)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# Execute tool calls
|
# Execute tool calls
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Pydantic configuration models mapping to config/config.yaml."""
|
"""Pydantic configuration models mapping to config/config.yaml."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -8,6 +9,14 @@ import yaml
|
|||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMode(StrEnum):
|
||||||
|
"""Runtime agent mode controlling permission behavior."""
|
||||||
|
|
||||||
|
NORMAL = "normal"
|
||||||
|
PLAN = "plan"
|
||||||
|
AUTO = "auto"
|
||||||
|
|
||||||
|
|
||||||
class LLMConfig(BaseModel):
|
class LLMConfig(BaseModel):
|
||||||
"""LLM backend configuration."""
|
"""LLM backend configuration."""
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
from app.models.config import PermissionsConfig, ToolsConfig
|
from app.models.config import AgentMode, PermissionsConfig, ToolsConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Type alias for the async prompt callback
|
# Type alias for the async prompt callback
|
||||||
PromptCallback = Callable[[str, str], Awaitable[bool]]
|
PromptCallback = Callable[[str, str], Awaitable[bool]]
|
||||||
|
|
||||||
|
# Detect shell redirects that write to files (>, >>, heredocs)
|
||||||
|
_WRITE_REDIRECT_PATTERN = re.compile(r"(?:>\s*\S|>>|<<)")
|
||||||
|
|
||||||
|
|
||||||
class PermissionDenied(Exception):
|
class PermissionDenied(Exception):
|
||||||
"""Raised when a tool is denied execution by permissions policy."""
|
"""Raised when a tool is denied execution by permissions policy."""
|
||||||
@@ -26,6 +30,10 @@ class PermissionsService:
|
|||||||
shows a modal dialog. Without a callback, unlisted tools are denied.
|
shows a modal dialog. Without a callback, unlisted tools are denied.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
READ_ONLY_TOOLS: frozenset[str] = frozenset({
|
||||||
|
"read_file", "list_dir", "grep_files", "find_files", "finish",
|
||||||
|
})
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: PermissionsConfig,
|
config: PermissionsConfig,
|
||||||
@@ -34,6 +42,16 @@ class PermissionsService:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self._tools_config = tools_config
|
self._tools_config = tools_config
|
||||||
self._prompt_callback: PromptCallback | None = None
|
self._prompt_callback: PromptCallback | None = None
|
||||||
|
self._mode: AgentMode = AgentMode.NORMAL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> AgentMode:
|
||||||
|
"""Current agent mode."""
|
||||||
|
return self._mode
|
||||||
|
|
||||||
|
@mode.setter
|
||||||
|
def mode(self, value: AgentMode) -> None:
|
||||||
|
self._mode = value
|
||||||
|
|
||||||
def set_prompt_callback(self, callback: PromptCallback) -> None:
|
def set_prompt_callback(self, callback: PromptCallback) -> None:
|
||||||
"""Set the async callback used to prompt the user for permission.
|
"""Set the async callback used to prompt the user for permission.
|
||||||
@@ -63,6 +81,16 @@ class PermissionsService:
|
|||||||
logger.info("Tool '%s' is in deny list — blocked", tool_name)
|
logger.info("Tool '%s' is in deny list — blocked", tool_name)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if self._mode == AgentMode.AUTO:
|
||||||
|
logger.debug("Tool '%s' auto-approved (AUTO mode)", tool_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self._mode == AgentMode.PLAN:
|
||||||
|
if tool_name not in self.READ_ONLY_TOOLS:
|
||||||
|
logger.info("Tool '%s' blocked in Plan mode (read-only tools only)", tool_name)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
if tool_name in self.config.auto_approve:
|
if tool_name in self.config.auto_approve:
|
||||||
logger.debug("Tool '%s' is auto-approved", tool_name)
|
logger.debug("Tool '%s' is auto-approved", tool_name)
|
||||||
return True
|
return True
|
||||||
@@ -104,6 +132,11 @@ class PermissionsService:
|
|||||||
logger.info("Shell command '%s' matches denied prefix '%s'", cmd, denied)
|
logger.info("Shell command '%s' matches denied prefix '%s'", cmd, denied)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Detect shell redirects that write to files — require approval
|
||||||
|
if _WRITE_REDIRECT_PATTERN.search(cmd):
|
||||||
|
logger.info("Shell command '%s' contains file-write redirect — requiring approval", cmd)
|
||||||
|
return None # fall through to user prompt
|
||||||
|
|
||||||
# Allowed commands: base executable match
|
# Allowed commands: base executable match
|
||||||
if shell_config.allowed_commands:
|
if shell_config.allowed_commands:
|
||||||
if base_cmd in shell_config.allowed_commands:
|
if base_cmd in shell_config.allowed_commands:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Shell tool: run_command."""
|
"""Shell tool: run_command."""
|
||||||
|
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -11,6 +12,9 @@ from app.tools.base import BaseTool
|
|||||||
|
|
||||||
_DEFAULT_TIMEOUT = 30
|
_DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Detect shell redirects that write to files (>, >>, heredocs)
|
||||||
|
_WRITE_REDIRECT_PATTERN = re.compile(r"(?:>\s*\S|>>|<<)")
|
||||||
|
|
||||||
|
|
||||||
class RunCommandParams(BaseModel):
|
class RunCommandParams(BaseModel):
|
||||||
"""Parameters for the run_command tool."""
|
"""Parameters for the run_command tool."""
|
||||||
@@ -43,6 +47,18 @@ class RunCommandTool(BaseTool):
|
|||||||
error=f"Command denied: matches blocked prefix '{denied}'",
|
error=f"Command denied: matches blocked prefix '{denied}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Defense-in-depth: flag file-write redirects in tool result
|
||||||
|
if _WRITE_REDIRECT_PATTERN.search(command):
|
||||||
|
return ToolResult(
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
tool_name=self.name,
|
||||||
|
status=ToolResultStatus.ERROR,
|
||||||
|
error=(
|
||||||
|
f"Command contains file-write redirect (>, >>, or <<) "
|
||||||
|
f"which bypasses file-write permissions. Use write_file instead."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Allow check: first token must be in allowed_commands
|
# Allow check: first token must be in allowed_commands
|
||||||
try:
|
try:
|
||||||
tokens = shlex.split(command)
|
tokens = shlex.split(command)
|
||||||
|
|||||||
@@ -10,18 +10,19 @@ from rich.panel import Panel
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.widgets import Header, Input, RichLog
|
from textual.widgets import Input, RichLog
|
||||||
from textual import work
|
from textual import work
|
||||||
|
|
||||||
from app.agent.context import SessionContext
|
from app.agent.context import SessionContext
|
||||||
from app.agent.loop import AgentLoop
|
from app.agent.loop import AgentLoop
|
||||||
from app.models.config import AppConfig
|
from app.models.config import AgentMode, AppConfig
|
||||||
from app.services.llm import LLMClient
|
from app.services.llm import LLMClient
|
||||||
from app.services.permissions import PermissionsService
|
from app.services.permissions import PermissionsService
|
||||||
from app.services.session import SessionManager
|
from app.services.session import SessionManager
|
||||||
from app.services.streaming import StreamHandler
|
from app.services.streaming import StreamHandler
|
||||||
from app.tools.registry import create_default_registry
|
from app.tools.registry import create_default_registry
|
||||||
from app.ui.widgets import (
|
from app.ui.widgets import (
|
||||||
|
HeaderPanel,
|
||||||
HistoryInput,
|
HistoryInput,
|
||||||
PermissionModal,
|
PermissionModal,
|
||||||
SessionResumeModal,
|
SessionResumeModal,
|
||||||
@@ -45,6 +46,7 @@ class SneakyCodeApp(App):
|
|||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("ctrl+c", "cancel_or_quit", "Cancel/Quit", show=False),
|
Binding("ctrl+c", "cancel_or_quit", "Cancel/Quit", show=False),
|
||||||
|
Binding("ctrl+p", "cycle_mode", "Cycle Mode"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, config: AppConfig, session_mgr: SessionManager | None = None) -> None:
|
def __init__(self, config: AppConfig, session_mgr: SessionManager | None = None) -> None:
|
||||||
@@ -61,10 +63,9 @@ class SneakyCodeApp(App):
|
|||||||
self._skill_runner = None
|
self._skill_runner = None
|
||||||
self._current_worker: Worker | None = None
|
self._current_worker: Worker | None = None
|
||||||
self._cancel_count = 0
|
self._cancel_count = 0
|
||||||
self.sub_title = config.llm.model
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield HeaderPanel(model_name=self._config.llm.model)
|
||||||
yield RichLog(id="chat-log", highlight=True, markup=True)
|
yield RichLog(id="chat-log", highlight=True, markup=True)
|
||||||
yield StreamingStatic("", id="streaming")
|
yield StreamingStatic("", id="streaming")
|
||||||
yield StatusBar()
|
yield StatusBar()
|
||||||
@@ -156,6 +157,10 @@ class SneakyCodeApp(App):
|
|||||||
event.input.record(user_input)
|
event.input.record(user_input)
|
||||||
log = self.query_one("#chat-log", RichLog)
|
log = self.query_one("#chat-log", RichLog)
|
||||||
|
|
||||||
|
# Echo user prompt (condensed for multi-line)
|
||||||
|
from app.utils.display import render_user_message
|
||||||
|
log.write(render_user_message(user_input))
|
||||||
|
|
||||||
# Handle slash commands
|
# Handle slash commands
|
||||||
if user_input.startswith("/"):
|
if user_input.startswith("/"):
|
||||||
await self._handle_slash_command(user_input, log)
|
await self._handle_slash_command(user_input, log)
|
||||||
@@ -186,6 +191,8 @@ class SneakyCodeApp(App):
|
|||||||
table.add_row("/session", "Show session info (messages, tokens, start time)")
|
table.add_row("/session", "Show session info (messages, tokens, start time)")
|
||||||
table.add_row("/models", "List available Ollama models")
|
table.add_row("/models", "List available Ollama models")
|
||||||
table.add_row("/models <name>", "Switch to a different model")
|
table.add_row("/models <name>", "Switch to a different model")
|
||||||
|
table.add_row("/mode", "Show current agent mode")
|
||||||
|
table.add_row("/mode normal|plan|auto", "Switch agent mode")
|
||||||
table.add_row("/skills", "List available skills")
|
table.add_row("/skills", "List available skills")
|
||||||
table.add_row("/<skill>", "Load a skill by name")
|
table.add_row("/<skill>", "Load a skill by name")
|
||||||
log.write(table)
|
log.write(table)
|
||||||
@@ -239,8 +246,23 @@ class SneakyCodeApp(App):
|
|||||||
else:
|
else:
|
||||||
new_model = parts[1].strip()
|
new_model = parts[1].strip()
|
||||||
self._config.llm.model = new_model
|
self._config.llm.model = new_model
|
||||||
self.sub_title = new_model
|
self.query_one(HeaderPanel).update_model(new_model)
|
||||||
log.write(Text(f"Switched to model: {new_model}", style="bold green"))
|
log.write(Text(f"Switched to model: {new_model}", style="bold green"))
|
||||||
|
elif cmd.startswith("/mode"):
|
||||||
|
parts = command.split(maxsplit=1)
|
||||||
|
if len(parts) == 1:
|
||||||
|
current = self._permissions.mode
|
||||||
|
log.write(Text(f"Current mode: {current.value}", style="cyan"))
|
||||||
|
else:
|
||||||
|
mode_str = parts[1].strip().lower()
|
||||||
|
try:
|
||||||
|
new_mode = AgentMode(mode_str)
|
||||||
|
except ValueError:
|
||||||
|
log.write(Text(f"Unknown mode: {mode_str}. Use normal, plan, or auto.", style="yellow"))
|
||||||
|
return
|
||||||
|
self._permissions.mode = new_mode
|
||||||
|
self.query_one(HeaderPanel).update_mode(new_mode)
|
||||||
|
log.write(Text(f"Switched to {new_mode.value} mode", style="bold green"))
|
||||||
elif cmd == "/skills":
|
elif cmd == "/skills":
|
||||||
if self._skills_manager:
|
if self._skills_manager:
|
||||||
skills = self._skills_manager.list_skills()
|
skills = self._skills_manager.list_skills()
|
||||||
@@ -302,12 +324,19 @@ class SneakyCodeApp(App):
|
|||||||
status_bar.start_streaming()
|
status_bar.start_streaming()
|
||||||
|
|
||||||
# Set up streaming UI callbacks
|
# Set up streaming UI callbacks
|
||||||
|
header = self.query_one(HeaderPanel)
|
||||||
|
|
||||||
def on_content(content: str) -> None:
|
def on_content(content: str) -> None:
|
||||||
streaming_widget.update(
|
streaming_widget.update(
|
||||||
Panel(Markdown(content), title="Assistant", border_style="green", expand=True)
|
Panel(Markdown(content), title="Assistant", border_style="green", expand=True)
|
||||||
)
|
)
|
||||||
streaming_widget.show_streaming()
|
streaming_widget.show_streaming()
|
||||||
status_bar.update_stream_tokens(len(content) // 4)
|
stream_tokens = len(content) // 4
|
||||||
|
status_bar.update_stream_tokens(stream_tokens)
|
||||||
|
header.update_tokens(
|
||||||
|
self._ctx.estimated_tokens + stream_tokens,
|
||||||
|
self._ctx.token_counter.budget,
|
||||||
|
)
|
||||||
|
|
||||||
def on_thinking() -> None:
|
def on_thinking() -> None:
|
||||||
streaming_widget.update(Text("Thinking...", style="dim"))
|
streaming_widget.update(Text("Thinking...", style="dim"))
|
||||||
@@ -331,6 +360,10 @@ class SneakyCodeApp(App):
|
|||||||
|
|
||||||
status_bar.stop_streaming()
|
status_bar.stop_streaming()
|
||||||
|
|
||||||
|
# Update token display in header
|
||||||
|
header = self.query_one(HeaderPanel)
|
||||||
|
header.update_tokens(self._ctx.estimated_tokens, self._ctx.token_counter.budget)
|
||||||
|
|
||||||
# Update skill indicator (skill may have been deactivated via finish_skill)
|
# Update skill indicator (skill may have been deactivated via finish_skill)
|
||||||
if self._skill_runner and not self._skill_runner.is_active:
|
if self._skill_runner and not self._skill_runner.is_active:
|
||||||
status_bar.set_active_skill(None)
|
status_bar.set_active_skill(None)
|
||||||
@@ -356,6 +389,21 @@ class SneakyCodeApp(App):
|
|||||||
log = self.query_one("#chat-log", RichLog)
|
log = self.query_one("#chat-log", RichLog)
|
||||||
log.write(Text("⚠ Cancelling... (press Ctrl+C again to quit)", style="yellow"))
|
log.write(Text("⚠ Cancelling... (press Ctrl+C again to quit)", style="yellow"))
|
||||||
|
|
||||||
|
def action_cycle_mode(self) -> None:
|
||||||
|
"""Cycle through agent modes: Normal → Plan → Auto → Normal."""
|
||||||
|
if self._permissions is None:
|
||||||
|
return
|
||||||
|
cycle = {
|
||||||
|
AgentMode.NORMAL: AgentMode.PLAN,
|
||||||
|
AgentMode.PLAN: AgentMode.AUTO,
|
||||||
|
AgentMode.AUTO: AgentMode.NORMAL,
|
||||||
|
}
|
||||||
|
new_mode = cycle[self._permissions.mode]
|
||||||
|
self._permissions.mode = new_mode
|
||||||
|
self.query_one(HeaderPanel).update_mode(new_mode)
|
||||||
|
log = self.query_one("#chat-log", RichLog)
|
||||||
|
log.write(Text(f"Mode: {new_mode.value}", style="bold green"))
|
||||||
|
|
||||||
async def on_unmount(self) -> None:
|
async def on_unmount(self) -> None:
|
||||||
"""Clean up the LLM client on app shutdown."""
|
"""Clean up the LLM client on app shutdown."""
|
||||||
if self._client is not None:
|
if self._client is not None:
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ Screen {
|
|||||||
Input {
|
Input {
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Header {
|
/* HeaderPanel styles are in DEFAULT_CSS on the widget itself */
|
||||||
dock: top;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,82 @@ from textual.widgets import Button, Input, Static
|
|||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
|
from app.models.config import AgentMode
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Header Panel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class HeaderPanel(Static):
|
||||||
|
"""Single-line header showing model name, agent mode, and token usage."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
HeaderPanel {
|
||||||
|
dock: top;
|
||||||
|
height: 1;
|
||||||
|
background: $accent;
|
||||||
|
color: $text;
|
||||||
|
padding: 0 2;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str) -> None:
|
||||||
|
super().__init__("")
|
||||||
|
self._model_name = model_name
|
||||||
|
self._mode: AgentMode = AgentMode.NORMAL
|
||||||
|
self._tokens: int = 0
|
||||||
|
self._budget: int = 0
|
||||||
|
|
||||||
|
def on_resize(self) -> None:
|
||||||
|
self._refresh_display()
|
||||||
|
|
||||||
|
def update_model(self, name: str) -> None:
|
||||||
|
"""Update the displayed model name."""
|
||||||
|
self._model_name = name
|
||||||
|
self._refresh_display()
|
||||||
|
|
||||||
|
def update_mode(self, mode: AgentMode) -> None:
|
||||||
|
"""Update the displayed agent mode."""
|
||||||
|
self._mode = mode
|
||||||
|
self._refresh_display()
|
||||||
|
|
||||||
|
def update_tokens(self, tokens: int, budget: int) -> None:
|
||||||
|
"""Update the token usage display."""
|
||||||
|
self._tokens = tokens
|
||||||
|
self._budget = budget
|
||||||
|
self._refresh_display()
|
||||||
|
|
||||||
|
def _refresh_display(self) -> None:
|
||||||
|
"""Rebuild the header text."""
|
||||||
|
left = Text.assemble(
|
||||||
|
("⚡ SneakyCode", "bold"),
|
||||||
|
" │ ",
|
||||||
|
(self._model_name, "bold"),
|
||||||
|
)
|
||||||
|
|
||||||
|
mode_styles = {
|
||||||
|
AgentMode.NORMAL: ("NORMAL", "bold black on white"),
|
||||||
|
AgentMode.PLAN: ("PLAN", "bold black on yellow"),
|
||||||
|
AgentMode.AUTO: ("AUTO", "bold white on red"),
|
||||||
|
}
|
||||||
|
mode_label, mode_style = mode_styles[self._mode]
|
||||||
|
mode_text = Text.assemble((" ", mode_style), (mode_label, mode_style), (" ", mode_style))
|
||||||
|
|
||||||
|
right = Text(f"~{self._tokens:,} / {self._budget:,} tokens")
|
||||||
|
|
||||||
|
# Pad between sections
|
||||||
|
total_content = left.plain + " " + mode_text.plain + " " + right.plain
|
||||||
|
available = self.size.width if self.size.width > 0 else 80
|
||||||
|
gap_left = max(1, (available - len(total_content)) // 2)
|
||||||
|
gap_right = max(1, available - len(total_content) - gap_left)
|
||||||
|
|
||||||
|
full = Text.assemble(
|
||||||
|
left, " " * gap_left, mode_text, " " * gap_right, right,
|
||||||
|
)
|
||||||
|
self.update(full)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Modal Dialogs
|
# Modal Dialogs
|
||||||
@@ -139,8 +215,6 @@ class StatusBar(Static):
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__("")
|
super().__init__("")
|
||||||
self._tokens: int = 0
|
|
||||||
self._budget: int = 0
|
|
||||||
self._iteration: int = 0
|
self._iteration: int = 0
|
||||||
self._max_iterations: int = 0
|
self._max_iterations: int = 0
|
||||||
self._streaming: bool = False
|
self._streaming: bool = False
|
||||||
@@ -149,12 +223,6 @@ class StatusBar(Static):
|
|||||||
self._stream_tokens: int = 0
|
self._stream_tokens: int = 0
|
||||||
self._active_skill: str | None = None
|
self._active_skill: str | None = None
|
||||||
|
|
||||||
def update_tokens(self, tokens: int, budget: int) -> None:
|
|
||||||
"""Update the token usage display."""
|
|
||||||
self._tokens = tokens
|
|
||||||
self._budget = budget
|
|
||||||
self._refresh_display()
|
|
||||||
|
|
||||||
def update_iteration(self, iteration: int, max_iterations: int) -> None:
|
def update_iteration(self, iteration: int, max_iterations: int) -> None:
|
||||||
"""Update the iteration count display."""
|
"""Update the iteration count display."""
|
||||||
self._iteration = iteration
|
self._iteration = iteration
|
||||||
@@ -200,8 +268,6 @@ class StatusBar(Static):
|
|||||||
parts.append(f"{spinner} Thinking")
|
parts.append(f"{spinner} Thinking")
|
||||||
if self._stream_tokens > 0:
|
if self._stream_tokens > 0:
|
||||||
parts.append(f"~{self._stream_tokens:,} tokens")
|
parts.append(f"~{self._stream_tokens:,} tokens")
|
||||||
if self._budget > 0:
|
|
||||||
parts.append(f"Tokens: ~{self._tokens:,} / {self._budget:,}")
|
|
||||||
if self._max_iterations > 0:
|
if self._max_iterations > 0:
|
||||||
parts.append(f"Iteration {self._iteration}/{self._max_iterations}")
|
parts.append(f"Iteration {self._iteration}/{self._max_iterations}")
|
||||||
self.update(Text(" \u2502 ".join(parts), style="dim"))
|
self.update(Text(" \u2502 ".join(parts), style="dim"))
|
||||||
|
|||||||
@@ -44,9 +44,22 @@ if TYPE_CHECKING:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def render_user_message(content: str) -> Panel:
|
def render_user_message(content: str) -> Text:
|
||||||
"""Render a user message as a styled panel."""
|
"""Render a condensed user prompt as a single styled line.
|
||||||
return Panel(content, title="You", border_style="cyan", expand=False)
|
|
||||||
|
Multi-line input is collapsed to the first line with a line count suffix.
|
||||||
|
Long single lines are truncated.
|
||||||
|
"""
|
||||||
|
lines = content.splitlines()
|
||||||
|
first = lines[0] if lines else content
|
||||||
|
max_len = 120
|
||||||
|
if len(first) > max_len:
|
||||||
|
first = first[:max_len] + "…"
|
||||||
|
suffix = f" (+{len(lines) - 1} lines)" if len(lines) > 1 else ""
|
||||||
|
text = Text()
|
||||||
|
text.append("You: ", style="bold cyan")
|
||||||
|
text.append(first + suffix, style="cyan")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def render_assistant_message(content: str) -> Panel:
|
def render_assistant_message(content: str) -> Panel:
|
||||||
@@ -223,8 +236,8 @@ def print_success(message: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def print_user_message(content: str) -> None:
|
def print_user_message(content: str) -> None:
|
||||||
"""Print a user message in a styled panel."""
|
"""Print a condensed user prompt line."""
|
||||||
console.print(Panel(content, title="You", border_style="cyan", expand=False))
|
console.print(render_user_message(content))
|
||||||
|
|
||||||
|
|
||||||
def print_assistant_message(content: str) -> None:
|
def print_assistant_message(content: str) -> None:
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ tools:
|
|||||||
- pytest
|
- pytest
|
||||||
- ruff
|
- ruff
|
||||||
- ls
|
- ls
|
||||||
- cat
|
|
||||||
- head
|
- head
|
||||||
- tail
|
- tail
|
||||||
- wc
|
- wc
|
||||||
@@ -56,6 +55,10 @@ tools:
|
|||||||
- grep
|
- grep
|
||||||
- find
|
- find
|
||||||
- echo
|
- echo
|
||||||
|
- which
|
||||||
|
- jq
|
||||||
|
- type
|
||||||
|
- file
|
||||||
denied_commands:
|
denied_commands:
|
||||||
- rm -rf /
|
- rm -rf /
|
||||||
- sudo
|
- sudo
|
||||||
|
|||||||
144
docs/ROADMAP.md
144
docs/ROADMAP.md
@@ -1,144 +0,0 @@
|
|||||||
# SneakyCode Implementation Roadmap
|
|
||||||
|
|
||||||
A phased plan progressing from bare-bones foundation to full autonomous coding agent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — Foundation: Models, Config, and Utilities
|
|
||||||
|
|
||||||
Establish the data layer and shared infrastructure everything else builds on.
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `app/models/config.py` | Pydantic v2 config model — load and validate `config/config.yaml` |
|
|
||||||
| `app/models/message.py` | Message schema (role, content, tool_calls) |
|
|
||||||
| `app/models/tool_call.py` | ToolCall and ToolResult schemas |
|
|
||||||
| `app/utils/logging.py` | Centralized logger with Rich handler |
|
|
||||||
| `app/utils/display.py` | Rich console output helpers (stub — expanded in Phase 2) |
|
|
||||||
| `app/utils/file_helpers.py` | Safe path resolution, binary detection, size guards |
|
|
||||||
| `app/utils/token_counter.py` | Approximate token usage tracking (character-based heuristic for v1) |
|
|
||||||
| `app/main.py` | Entrypoint stub — arg parsing, config load, Rich console setup |
|
|
||||||
|
|
||||||
**Exit criteria:** `python -m app.main --help` runs, config loads and validates, models can be instantiated and serialized.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — TUI and Interactive Shell
|
|
||||||
|
|
||||||
Get a working interactive terminal before wiring up the LLM.
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `app/main.py` | Rich-based interactive REPL loop — prompt for user input, display responses |
|
|
||||||
| `app/utils/display.py` | Formatted output for agent messages, tool calls, errors, token usage |
|
|
||||||
| `app/agent/context.py` | Session state and conversation history management |
|
|
||||||
|
|
||||||
**Exit criteria:** User can type messages into a styled REPL, see them echoed back with formatting, and conversation history is tracked in memory.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 — LLM Integration (Ollama)
|
|
||||||
|
|
||||||
Connect to the local LLM and stream responses into the TUI.
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `app/services/llm.py` | Async httpx client wrapping Ollama's OpenAI-compatible `/v1/chat/completions` endpoint |
|
|
||||||
| `app/services/streaming.py` | SSE parsing, Rich live display, tool call extraction from accumulated stream |
|
|
||||||
|
|
||||||
**Integration:** Wire LLM into the REPL — user message goes to LLM, streamed response displays in real time.
|
|
||||||
|
|
||||||
**Exit criteria:** User can chat with the local model through the TUI with streamed output. Tool call JSON is parsed from the stream but not yet executed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4 — Tool Framework and Core Tools
|
|
||||||
|
|
||||||
Build the tool abstraction and implement safe, read-only tools first.
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `app/tools/base.py` | `BaseTool` ABC and `ToolResult` dataclass |
|
|
||||||
| `app/tools/registry.py` | Tool registration, discovery, and JSON schema export for LLM system prompt |
|
|
||||||
| `app/services/permissions.py` | Two-tier approval gating (auto-approve reads; prompt for writes/deletes/shell) |
|
|
||||||
| `app/tools/filesystem.py` | `read_file`, `list_dir` |
|
|
||||||
| `app/tools/search.py` | `grep_files`, `find_files` |
|
|
||||||
|
|
||||||
**Exit criteria:** Tools register themselves, schemas export correctly for inclusion in the system prompt, read-only tools execute and return `ToolResult` objects. Permissions service gates execution.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5 — Agent Loop (ReAct)
|
|
||||||
|
|
||||||
The core autonomy layer — reason, act, observe, repeat.
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `app/agent/loop.py` | ReAct cycle: send conversation to LLM, parse tool calls, execute, feed results back, repeat |
|
|
||||||
|
|
||||||
**Key behaviors:**
|
|
||||||
- System prompt constructed with tool schemas from registry
|
|
||||||
- Permissions checks before each tool execution
|
|
||||||
- Loop termination on: plain-text response (no tool calls), explicit `finish` tool call, or `max_iterations` exceeded
|
|
||||||
|
|
||||||
**Exit criteria:** Agent can autonomously answer questions about the codebase by chaining `read_file`, `list_dir`, `grep_files`, and `find_files` tool calls in a multi-turn loop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6 — Write Tools and Shell
|
|
||||||
|
|
||||||
Unlock the agent's ability to modify code and run commands.
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `app/tools/filesystem.py` | `write_file`, `make_dir`, `delete_file` (additions to existing module) |
|
|
||||||
| `app/tools/edit.py` | `str_replace` (unique-match required), `patch_apply` |
|
|
||||||
| `app/tools/shell.py` | `run_command` with command allow/deny lists and output truncation |
|
|
||||||
|
|
||||||
**All write/shell operations gated through permissions service.**
|
|
||||||
|
|
||||||
**Exit criteria:** Agent can autonomously create files, edit code via string replacement, and run shell commands — all with user approval for destructive operations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7 — Polish and Hardening
|
|
||||||
|
|
||||||
Production-readiness: error handling, resource limits, and documentation.
|
|
||||||
|
|
||||||
| Area | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| Error handling | Recovery from malformed tool calls, LLM errors, network timeouts in agent loop |
|
|
||||||
| Token budget | Conversation truncation or summarization when approaching context limit |
|
|
||||||
| Graceful shutdown | Clean Ctrl+C handling, session state preservation |
|
|
||||||
| Testing | End-to-end integration tests (`tests/integration/`), unit tests (`tests/unit/`) |
|
|
||||||
| Documentation | `README.md` with setup and usage instructions, `docs/tools.md` tool reference |
|
|
||||||
|
|
||||||
**Exit criteria:** Agent handles edge cases gracefully, tests pass, and a new user can set up and use the project from the README alone.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Coverage
|
|
||||||
|
|
||||||
Every file from the project structure in CLAUDE.md is accounted for:
|
|
||||||
|
|
||||||
| File | Phase |
|
|
||||||
|------|-------|
|
|
||||||
| `app/main.py` | 1, 2 |
|
|
||||||
| `app/models/config.py` | 1 |
|
|
||||||
| `app/models/message.py` | 1 |
|
|
||||||
| `app/models/tool_call.py` | 1 |
|
|
||||||
| `app/utils/logging.py` | 1 |
|
|
||||||
| `app/utils/display.py` | 1, 2 |
|
|
||||||
| `app/utils/file_helpers.py` | 1 |
|
|
||||||
| `app/utils/token_counter.py` | 1 |
|
|
||||||
| `app/agent/context.py` | 2 |
|
|
||||||
| `app/services/llm.py` | 3 |
|
|
||||||
| `app/services/streaming.py` | 3 |
|
|
||||||
| `app/tools/base.py` | 4 |
|
|
||||||
| `app/tools/registry.py` | 4 |
|
|
||||||
| `app/services/permissions.py` | 4 |
|
|
||||||
| `app/tools/filesystem.py` | 4, 6 |
|
|
||||||
| `app/tools/search.py` | 4 |
|
|
||||||
| `app/agent/loop.py` | 5 |
|
|
||||||
| `app/tools/edit.py` | 6 |
|
|
||||||
| `app/tools/shell.py` | 6 |
|
|
||||||
1802
docs/superpowers/plans/2026-03-11-textual-tui.md
Normal file
1802
docs/superpowers/plans/2026-03-11-textual-tui.md
Normal file
File diff suppressed because it is too large
Load Diff
192
docs/superpowers/specs/2026-03-11-textual-tui-design.md
Normal file
192
docs/superpowers/specs/2026-03-11-textual-tui-design.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Textual TUI Redesign — Design Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace the current sequential print-and-scroll terminal UI with a full persistent split-screen TUI using Textual. Input is pinned at the bottom, scrollable message history above, with a header showing app/model info and a footer showing token usage and iteration count.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------- Header --------------------+
|
||||||
|
| SneakyCode qwen2.5-coder:32b |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| |
|
||||||
|
| +--- You ---+ |
|
||||||
|
| | prompt | <- RichLog widget |
|
||||||
|
| +-----------+ (handles own scrolling) |
|
||||||
|
| |
|
||||||
|
| Thinking... |
|
||||||
|
| |
|
||||||
|
| +-- Assistant --+ |
|
||||||
|
| | response... | |
|
||||||
|
| +---------------+ |
|
||||||
|
| |
|
||||||
|
| > read_file README.md -- 148 lines, 5128 ch |
|
||||||
|
| > grep_files "pattern" -- 3 matches |
|
||||||
|
| |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| Tokens: ~1,511 / 32,000 | Iteration 5/25 | <- StatusBar
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| > [input cursor] | <- Input widget
|
||||||
|
+-----------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**Widget hierarchy (no VerticalScroll wrapper — RichLog handles its own scrolling):**
|
||||||
|
- `Header` — Textual built-in, title="SneakyCode", subtitle=model name
|
||||||
|
- `RichLog` (id="chat-log") — main scroll area, accepts Rich renderables via `.write()`
|
||||||
|
- `StreamingStatic` — persistent hidden `Static` widget, shown/hidden during streaming (avoids mount/unmount overhead)
|
||||||
|
- `StatusBar` — custom `Static` widget, 1 row, docked above Input
|
||||||
|
- `Input` — Textual built-in, pinned at bottom
|
||||||
|
|
||||||
|
## New Files
|
||||||
|
|
||||||
|
### `app/ui/app.py` — Textual App
|
||||||
|
|
||||||
|
SneakyCodeApp subclasses `textual.app.App`. Responsibilities:
|
||||||
|
|
||||||
|
- `compose()` yields: Header, RichLog(id="chat-log"), StreamingStatic(id="streaming"), StatusBar(id="status"), Input
|
||||||
|
- `on_input_submitted()` handler: reads input value, clears input, writes user panel to chat log, dispatches agent turn as a worker
|
||||||
|
- Agent turn runs via `run_worker()` (async worker, NOT threaded) so the UI stays responsive. Since the worker is async and on the event loop, widget methods can be called directly — no `call_from_thread()` needed.
|
||||||
|
- Slash commands (/quit, /history, /clear, /save, /session) parsed from input before dispatching to agent
|
||||||
|
- Holds references to config, SessionContext, AgentLoop (created in `on_mount`)
|
||||||
|
- Header subtitle set to model name from config
|
||||||
|
- `on_worker_state_changed()` handler: catches worker errors and writes error panels to RichLog
|
||||||
|
- Ctrl+C binding: cancels the running agent worker (does NOT quit the app). A second Ctrl+C or `/quit` exits.
|
||||||
|
|
||||||
|
### `app/ui/widgets.py` — Custom Widgets
|
||||||
|
|
||||||
|
**StatusBar** — A simple `Static` widget styled as a footer bar. Displays token usage and iteration count. Updated by the agent loop after each LLM step via `status_bar.update(renderable)`.
|
||||||
|
|
||||||
|
**StreamingStatic** — A `Static` widget that stays mounted but hidden. During streaming, it becomes visible and receives `update()` calls with partial content. When streaming ends, it is hidden and its content is cleared. This avoids the overhead of mounting/unmounting on every LLM response.
|
||||||
|
|
||||||
|
### `app/ui/styles.tcss` — Textual CSS
|
||||||
|
|
||||||
|
Layout rules:
|
||||||
|
- RichLog fills available height (fraction-based sizing, e.g. `height: 1fr`)
|
||||||
|
- StreamingStatic: `display: none` by default, shown during streaming
|
||||||
|
- StatusBar is 1 row, docked bottom above Input
|
||||||
|
- Input is 1 row, docked at very bottom
|
||||||
|
- Color scheme matches existing SNEAKYCODE_THEME (cyan for user, green for assistant, magenta for tools, dim for metadata)
|
||||||
|
|
||||||
|
## Modified Files
|
||||||
|
|
||||||
|
### `app/main.py`
|
||||||
|
|
||||||
|
- Remove `_run_repl()` async function entirely
|
||||||
|
- Remove `console.input()` usage
|
||||||
|
- `main()` creates config, runs preflight via `asyncio.run(_preflight(config))` (before Textual starts — this is fine, separate event loop), then instantiates and runs `SneakyCodeApp(config).run()`
|
||||||
|
- CLI arg parsing stays (--config, -v, --log-file)
|
||||||
|
- Session resume: `_offer_session_resume()` moves into `SneakyCodeApp.on_mount()` — instead of `console.input()`, push a modal screen asking "Resume previous session? [y/n]" with button/key handlers
|
||||||
|
- Auto-save: triggers after each agent turn completes (in the worker completion handler)
|
||||||
|
- SIGTERM handler: removed — Textual manages its own signal handling and shutdown lifecycle
|
||||||
|
|
||||||
|
### `app/services/streaming.py`
|
||||||
|
|
||||||
|
- Remove `from rich.live import Live` and `from rich.spinner import Spinner`
|
||||||
|
- `process_stream()` no longer creates a `Rich.Live` context
|
||||||
|
- Instead, accepts callback parameters:
|
||||||
|
- `on_content: Callable[[str], None]` — called with accumulated content on each content chunk
|
||||||
|
- `on_thinking: Callable[[], None]` — called once when first reasoning token arrives
|
||||||
|
- `on_done: Callable[[], None]` — called when streaming completes
|
||||||
|
- **Throttling:** Content callback fires at most every 100ms (track last update time, skip intermediate chunks). Final content always fires on stream end.
|
||||||
|
- Since the agent runs as an async worker (on the event loop), callbacks can directly call widget methods — no `call_from_thread()` needed.
|
||||||
|
- All accumulation and tool-call parsing logic stays identical
|
||||||
|
|
||||||
|
### `app/utils/display.py`
|
||||||
|
|
||||||
|
- All `print_*` functions become `render_*` functions that return Rich renderables:
|
||||||
|
- `render_user_message(content) -> Panel`
|
||||||
|
- `render_assistant_message(content) -> Panel`
|
||||||
|
- `render_tool_call(name, args) -> Text`
|
||||||
|
- `render_tool_result(name, output, is_error) -> Text`
|
||||||
|
- `render_iteration_header(iteration, max_iter) -> Text`
|
||||||
|
- `render_warning(message) -> Text`
|
||||||
|
- `render_error(message) -> Text`
|
||||||
|
- `print_banner()` removed — Header widget replaces it
|
||||||
|
- `print_token_usage()` becomes `render_token_usage() -> Text` for the StatusBar
|
||||||
|
- `print_history()` becomes `render_history() -> Table` — written to RichLog, may need width constraints for narrow terminals
|
||||||
|
- A `DisplayAdapter` class wraps a `RichLog` reference and provides `write_user_message()`, `write_tool_call()`, etc. methods that call `render_*` then `rich_log.write()`
|
||||||
|
|
||||||
|
### `app/agent/loop.py`
|
||||||
|
|
||||||
|
- `AgentLoop.__init__()` accepts a `DisplayAdapter` instead of calling `display.py` print functions directly
|
||||||
|
- All display calls route through the adapter: `self._display.write_tool_call(name, args)`, `self._display.write_iteration_header(i, max)`, etc.
|
||||||
|
- `_execute_tool_calls()` becomes `async def _execute_tool_calls()` to support async permission checks
|
||||||
|
- The loop logic (ReAct pattern, retry, truncation) is unchanged
|
||||||
|
|
||||||
|
### `app/services/permissions.py`
|
||||||
|
|
||||||
|
- `PermissionsService.check()` becomes `async def check()`
|
||||||
|
- Instead of `rich.prompt.Confirm.ask()` (blocking stdin read), it:
|
||||||
|
1. Creates an `asyncio.Event`
|
||||||
|
2. Posts a custom message to the app requesting a permission modal
|
||||||
|
3. The app pushes a modal screen with the permission question and approve/deny buttons
|
||||||
|
4. When the user responds, the modal sets the event and stores the result
|
||||||
|
5. `check()` awaits the event and reads the result
|
||||||
|
- Edge cases: dismiss without choosing = deny. Ctrl+C during modal = deny. Focus returns to Input after modal dismisses.
|
||||||
|
|
||||||
|
### `app/utils/logging.py`
|
||||||
|
|
||||||
|
- **Critical change:** The shared `console = Console()` instance will corrupt the Textual display since Textual takes exclusive terminal control
|
||||||
|
- When running under Textual: disable `RichHandler` (console handler), keep only the file handler
|
||||||
|
- Add a `setup_logging_for_tui()` function that reconfigures logging to file-only mode
|
||||||
|
- Called from `SneakyCodeApp.on_mount()` before any agent work begins
|
||||||
|
- The `console` object still exists but should not be used for output during TUI mode — all output goes through the DisplayAdapter
|
||||||
|
- Consider: `--log-file` becomes required (or auto-set to a default) when running in TUI mode, so logs are not lost
|
||||||
|
|
||||||
|
## Unchanged Files
|
||||||
|
|
||||||
|
- `app/services/llm.py` — HTTP client, SSE parsing untouched
|
||||||
|
- `app/agent/context.py` — session state untouched
|
||||||
|
- `app/models/*` — all data models untouched
|
||||||
|
- `app/tools/*` — all tool implementations untouched
|
||||||
|
- `app/utils/file_helpers.py` — path safety untouched
|
||||||
|
- `app/utils/token_counter.py` — token counting untouched
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### Streaming in Textual
|
||||||
|
|
||||||
|
The agent loop runs as an async worker (on the event loop, NOT threaded). During streaming:
|
||||||
|
|
||||||
|
1. App shows `StreamingStatic` widget, writes "Thinking..." initially
|
||||||
|
2. Worker calls `StreamHandler.process_stream(chunks, on_content=..., on_thinking=..., on_done=...)`
|
||||||
|
3. `on_content` callback: updates `StreamingStatic` with `Panel(Markdown(partial_content), title="Assistant", border_style="green")` — throttled to ~100ms intervals
|
||||||
|
4. `on_done` callback: hides `StreamingStatic`, writes final content to `RichLog` via `DisplayAdapter`
|
||||||
|
|
||||||
|
Since the worker is async (not threaded), callbacks run on the event loop and can call widget methods directly.
|
||||||
|
|
||||||
|
### Permission Prompts
|
||||||
|
|
||||||
|
1. Agent loop (in async worker) calls `await permissions.check(operation, details)`
|
||||||
|
2. `check()` creates an `asyncio.Event` and posts `PermissionRequest` message to the app
|
||||||
|
3. App handles `PermissionRequest`: pushes a modal screen with the question, approve/deny buttons
|
||||||
|
4. Modal screen: on button press, stores result and sets the event
|
||||||
|
5. `check()` awaits the event, reads result, returns approved/denied
|
||||||
|
6. Focus management: Input loses focus when modal appears, regains focus when modal dismisses
|
||||||
|
7. Default on dismiss/Ctrl+C: deny
|
||||||
|
|
||||||
|
### Cancellation
|
||||||
|
|
||||||
|
- Ctrl+C (first press): cancels the running agent worker via `worker.cancel()`. The agent loop should check for cancellation between iterations.
|
||||||
|
- Ctrl+C (second press) or `/quit`: exits the app via `app.exit()`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Add `textual>=4.0.0` to pyproject.toml dependencies
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Run the app — header shows app name + model, no console corruption
|
||||||
|
2. Type a prompt — user panel appears in scroll area, input clears
|
||||||
|
3. During LLM streaming — assistant response types out live (throttled) in the scroll area
|
||||||
|
4. Thinking indicator shows during reasoning-only phases
|
||||||
|
5. Tool calls appear as compact lines in the scroll area
|
||||||
|
6. Footer shows token usage and iteration count, updating each step
|
||||||
|
7. Scroll area auto-scrolls to bottom on new content
|
||||||
|
8. /quit, /clear, /history commands work from the input
|
||||||
|
9. Permission prompts show as modal, approve/deny work, focus returns to input
|
||||||
|
10. Ctrl+C cancels running agent turn without quitting
|
||||||
|
11. Worker errors display as error panels in the scroll area
|
||||||
|
12. Logging goes to file only — no console corruption
|
||||||
|
13. Session resume works on startup via modal dialog
|
||||||
@@ -1 +1,21 @@
|
|||||||
Pressing up should cycle history like claude code.
|
Pressing up should cycle history like claude code.
|
||||||
|
|
||||||
|
~~Remove the user's input from output "you" - it's not needed.~~ Brought back as a condensed one-liner (first line + line count for multi-line).
|
||||||
|
|
||||||
|
Smart shell auto-approve: auto-approve `run_command` when the base executable is in the `allowed_commands` list and the full command doesn't match any `denied_commands` prefix. Only prompt the user for commands whose base executable is unlisted. Currently all shell commands prompt regardless, which is tedious for safe read-only commands like `git branch` or `ls`. The allow/deny lists in `ShellToolConfig` already define what's safe — the permissions service just needs to be shell-aware.
|
||||||
|
|
||||||
|
Show a token count or some other display for when the model is "thinking" for a long period of time. I want a way for the user to know the model is working on it.
|
||||||
|
|
||||||
|
/models command to show models available and temporarly change models in the session
|
||||||
|
|
||||||
|
pass a directory to the tool so that it uses that directory as it's root for commands.
|
||||||
|
|
||||||
|
add a skills directory so we can prompt our own skills for the tool to use similar to Claude Code
|
||||||
|
|
||||||
|
need not only a session log, but also a log of what the llm is thinking and how it's working somehow. I need a way to see behind the curtain.
|
||||||
|
|
||||||
|
# Left of from Phase 7 of old roadmap - finish these first
|
||||||
|
- Permission modal auto-approves (TODO: proper modal dialog)
|
||||||
|
- Session resume auto-resumes (TODO: modal y/n)
|
||||||
|
- LLM client cleanup on unmount not yet wired
|
||||||
|
- No automated TUI tests (Textual's AppTest can be added later)
|
||||||
@@ -21,10 +21,10 @@ from app.utils.display import (
|
|||||||
|
|
||||||
|
|
||||||
class TestRenderFunctions:
|
class TestRenderFunctions:
|
||||||
def test_render_user_message_returns_panel(self) -> None:
|
def test_render_user_message_returns_text(self) -> None:
|
||||||
result = render_user_message("hello")
|
result = render_user_message("hello")
|
||||||
assert isinstance(result, Panel)
|
assert isinstance(result, Text)
|
||||||
assert result.title == "You"
|
assert "hello" in result.plain
|
||||||
|
|
||||||
def test_render_assistant_message_returns_panel(self) -> None:
|
def test_render_assistant_message_returns_panel(self) -> None:
|
||||||
result = render_assistant_message("response")
|
result = render_assistant_message("response")
|
||||||
@@ -72,7 +72,7 @@ class TestDisplayAdapter:
|
|||||||
adapter.write_user_message("hello")
|
adapter.write_user_message("hello")
|
||||||
mock_log.write.assert_called_once()
|
mock_log.write.assert_called_once()
|
||||||
arg = mock_log.write.call_args[0][0]
|
arg = mock_log.write.call_args[0][0]
|
||||||
assert isinstance(arg, Panel)
|
assert isinstance(arg, Text)
|
||||||
|
|
||||||
def test_write_tool_call(self) -> None:
|
def test_write_tool_call(self) -> None:
|
||||||
mock_log = MagicMock()
|
mock_log = MagicMock()
|
||||||
|
|||||||
@@ -90,6 +90,6 @@ class TestRunCommandTool:
|
|||||||
# Create a file in the workspace to verify cwd
|
# Create a file in the workspace to verify cwd
|
||||||
(ws / "marker.txt").write_text("found")
|
(ws / "marker.txt").write_text("found")
|
||||||
tool = RunCommandTool(ws, cfg)
|
tool = RunCommandTool(ws, cfg)
|
||||||
result = tool.run("tc-9", {"command": "cat marker.txt"})
|
result = tool.run("tc-9", {"command": "head marker.txt"})
|
||||||
assert result.status == ToolResultStatus.SUCCESS
|
assert result.status == ToolResultStatus.SUCCESS
|
||||||
assert "found" in result.output
|
assert "found" in result.output
|
||||||
|
|||||||
Reference in New Issue
Block a user