Compare commits

...

9 Commits

Author SHA1 Message Date
0886727437 Merge branch 'feature/header-panel-agent-modes' 2026-03-11 21:36:27 -05:00
638aecb561 feat: add custom HeaderPanel widget and switchable agent modes
Replace built-in Header with a custom HeaderPanel showing model name,
mode badge, and live token usage. Add AgentMode enum (normal/plan/auto)
with mode-aware permission gating — Plan mode restricts to read-only
tools, Auto mode auto-approves everything. Includes /mode slash command
and Ctrl+P keybinding to cycle modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:36:23 -05:00
b878408f3e Merge branch 'fix/remove-cat-allowed-command' 2026-03-11 21:15:05 -05:00
5b5c3098bb fix: remove cat from allowed shell commands and fix stale display tests
Remove cat from allowed_commands to close shell redirect bypass vector
(read_file provides safer alternative). Update display tests to match
render_user_message returning Text instead of Panel, and shell test to
use head instead of cat.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:14:21 -05:00
4e3da84578 Merge branch 'fix/display-bug-and-shell-write-bypass' 2026-03-11 20:58:55 -05:00
2ad3df521d fix: display assistant content with finish tool call + block shell write redirects
Bug 1: Assistant text content was silently dropped when the LLM response
included both content and tool calls (e.g. finish with a summary). Now
content is displayed before tool call execution regardless.

Bug 2: Shell redirect operators (>, >>, <<) allowed bypassing file-write
permissions when the base command (e.g. cat) was in the allowed list.
Redirects now require explicit user approval in permissions, and the
shell tool itself blocks them as defense-in-depth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:58:07 -05:00
4496fce354 docs: update README with full config reference, remove completed roadmap
Expands README with complete config.yaml reference, CLI options table,
skills documentation, and updated command list. Removes the old roadmap
(all phases complete). Updates tweaks.md with current design notes.
Adds .sneakycode/ to .gitignore and includes superpowers design specs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:05:16 -05:00
133bcbda57 feat: re-echo condensed user prompt in chat log
Shows user input as a compact one-liner ("You: prompt text") instead of
a full panel. Multi-line input is collapsed to the first line with a
"(+N lines)" suffix, and long lines are truncated at 120 chars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:05:10 -05:00
7705008b9c fix: prevent /no_think tag from leaking into user message content
The /no_think directive was appended directly to the user's message text,
causing the LLM to interpret it as user input (e.g., referencing it as a
directory path). Now injected as a separate system message after the last
user message. Also adds which, jq, type, and file to shell allowed_commands
(were mistakenly placed in permissions.auto_approve).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:04:47 -05:00
17 changed files with 2362 additions and 210 deletions

3
.gitignore vendored
View File

@@ -34,3 +34,6 @@ htmlcov/
# Worktrees
.worktrees/
# SneakyCode local data
.sneakycode/

View File

@@ -26,14 +26,19 @@ pip install -e ".[dev]"
## Configuration
Edit `config/config.yaml` to configure the agent. Key settings:
Edit `config/config.yaml` to configure the agent. The full configuration reference:
```yaml
llm:
model: "qwen3.5:latest" # Ollama model name
endpoint: "http://localhost:11434" # Ollama endpoint
api_path: "/v1/chat/completions" # API endpoint path
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:
max_iterations: 25 # Max tool-call iterations per turn
@@ -43,6 +48,7 @@ agent:
truncation_threshold: 0.85 # Budget fraction that triggers truncation
session:
session_dir: ".sneakycode/sessions" # Directory for session files
auto_save: true # Save session after each turn
max_session_age_hours: 72 # Auto-cleanup old sessions
offer_resume: true # Offer to resume on startup
@@ -51,6 +57,48 @@ permissions:
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]
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.
@@ -58,9 +106,12 @@ Environment variable `SNEAKYCODE_CONFIG` can override the config file path.
## Usage
```bash
# Start the interactive REPL
# Start the interactive TUI
sneakycode
# Open a specific project directory
sneakycode /path/to/project
# Or run directly
python -m app.main
@@ -68,25 +119,38 @@ python -m app.main
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
| Command | Description |
|------------|--------------------------------------|
| `/quit` | Save session and exit |
|-------------------|----------------------------------------------------|
| `/help` | Show available commands |
| `/quit` | Save session and exit (also `/exit`, `/bye`) |
| `/history` | Show conversation history |
| `/clear` | Clear conversation history |
| `/save` | Manually save session |
| `/session` | Show session info (messages, tokens) |
| `/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
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
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 |
|------------|-------------------------------------------------|---------------|
@@ -96,6 +160,17 @@ SneakyCode provides 11 tools across 5 categories. See [docs/tools.md](docs/tools
| Edit | `str_replace`, `patch_apply` | User confirm |
| Shell | `run_command` | User confirm |
| 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
@@ -121,6 +196,7 @@ app/
├── models/ # Pydantic config and message schemas
├── services/ # LLM client, streaming, permissions, session persistence
├── tools/ # Tool implementations (one file per group)
├── ui/ # Textual TUI application and widgets
└── utils/ # Logging, display, file helpers, token counter
config/
└── config.yaml # Application configuration

View File

@@ -7,7 +7,7 @@ import time
from typing import TYPE_CHECKING, Any
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.tool_call import ToolCall, ToolResult, ToolResultStatus
from app.services.llm import LLMClient, LLMConnectionError, LLMError, LLMStreamError
@@ -59,6 +59,12 @@ class AgentLoop:
self._skills = skills_manager
self._skill_runner = skill_runner
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._cancelled = False
@@ -89,29 +95,36 @@ class AgentLoop:
f"\n\nCurrently active skill: {self._skill_runner.active_skill_name}. "
"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
def _get_messages_with_system_prompt(self) -> list[Message]:
"""Prepend the system prompt to conversation history.
When thinking is disabled, injects a /no_think tag into the last user
message so Qwen 3.x (and similar) chat templates see it regardless of
where tool-result messages fall in the history.
When thinking is disabled, appends a system-level /no_think directive
after the last user message so Qwen 3.x (and similar) chat templates
see it, without polluting the user's actual message content.
"""
system_msg = Message(role="system", content=self._system_prompt)
history = self._ctx.get_history()
if not self._config.llm.thinking and history:
history = list(history)
# Find last user message and insert a system hint after it
for i in range(len(history) - 1, -1, -1):
if history[i].role == "user":
original = history[i]
history[i] = Message(
role="user",
content=(original.content or "") + " /no_think",
tool_call_id=original.tool_call_id,
name=original.name,
no_think_msg = Message(
role="system",
content="/no_think",
)
history.insert(i + 1, no_think_msg)
break
return [system_msg] + history
@@ -217,10 +230,12 @@ class AgentLoop:
# Successful response — reset streak
reasoning_only_streak = 0
# No tool calls → task complete (plain text response)
if not assistant_msg.tool_calls:
# 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)
if not assistant_msg.tool_calls:
break
# Execute tool calls

View File

@@ -1,6 +1,7 @@
"""Pydantic configuration models mapping to config/config.yaml."""
import os
from enum import StrEnum
from pathlib import Path
from typing import Any
@@ -8,6 +9,14 @@ import yaml
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):
"""LLM backend configuration."""

View File

@@ -4,16 +4,20 @@ from __future__ import annotations
import json
import logging
import re
import shlex
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__)
# Type alias for the async prompt callback
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):
"""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.
"""
READ_ONLY_TOOLS: frozenset[str] = frozenset({
"read_file", "list_dir", "grep_files", "find_files", "finish",
})
def __init__(
self,
config: PermissionsConfig,
@@ -34,6 +42,16 @@ class PermissionsService:
self.config = config
self._tools_config = tools_config
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:
"""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)
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:
logger.debug("Tool '%s' is auto-approved", tool_name)
return True
@@ -104,6 +132,11 @@ class PermissionsService:
logger.info("Shell command '%s' matches denied prefix '%s'", cmd, denied)
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
if shell_config.allowed_commands:
if base_cmd in shell_config.allowed_commands:

View File

@@ -1,5 +1,6 @@
"""Shell tool: run_command."""
import re
import shlex
import subprocess
from typing import Any
@@ -11,6 +12,9 @@ from app.tools.base import BaseTool
_DEFAULT_TIMEOUT = 30
# Detect shell redirects that write to files (>, >>, heredocs)
_WRITE_REDIRECT_PATTERN = re.compile(r"(?:>\s*\S|>>|<<)")
class RunCommandParams(BaseModel):
"""Parameters for the run_command tool."""
@@ -43,6 +47,18 @@ class RunCommandTool(BaseTool):
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
try:
tokens = shlex.split(command)

View File

@@ -10,18 +10,19 @@ from rich.panel import Panel
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Header, Input, RichLog
from textual.widgets import Input, RichLog
from textual import work
from app.agent.context import SessionContext
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.permissions import PermissionsService
from app.services.session import SessionManager
from app.services.streaming import StreamHandler
from app.tools.registry import create_default_registry
from app.ui.widgets import (
HeaderPanel,
HistoryInput,
PermissionModal,
SessionResumeModal,
@@ -45,6 +46,7 @@ class SneakyCodeApp(App):
BINDINGS = [
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:
@@ -61,10 +63,9 @@ class SneakyCodeApp(App):
self._skill_runner = None
self._current_worker: Worker | None = None
self._cancel_count = 0
self.sub_title = config.llm.model
def compose(self) -> ComposeResult:
yield Header()
yield HeaderPanel(model_name=self._config.llm.model)
yield RichLog(id="chat-log", highlight=True, markup=True)
yield StreamingStatic("", id="streaming")
yield StatusBar()
@@ -156,6 +157,10 @@ class SneakyCodeApp(App):
event.input.record(user_input)
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
if user_input.startswith("/"):
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("/models", "List available Ollama models")
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("/<skill>", "Load a skill by name")
log.write(table)
@@ -239,8 +246,23 @@ class SneakyCodeApp(App):
else:
new_model = parts[1].strip()
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"))
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":
if self._skills_manager:
skills = self._skills_manager.list_skills()
@@ -302,12 +324,19 @@ class SneakyCodeApp(App):
status_bar.start_streaming()
# Set up streaming UI callbacks
header = self.query_one(HeaderPanel)
def on_content(content: str) -> None:
streaming_widget.update(
Panel(Markdown(content), title="Assistant", border_style="green", expand=True)
)
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:
streaming_widget.update(Text("Thinking...", style="dim"))
@@ -331,6 +360,10 @@ class SneakyCodeApp(App):
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)
if self._skill_runner and not self._skill_runner.is_active:
status_bar.set_active_skill(None)
@@ -356,6 +389,21 @@ class SneakyCodeApp(App):
log = self.query_one("#chat-log", RichLog)
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:
"""Clean up the LLM client on app shutdown."""
if self._client is not None:

View File

@@ -55,8 +55,8 @@ Screen {
Input {
dock: bottom;
margin: 0;
border: none;
padding: 0 1;
}
Header {
dock: top;
}
/* HeaderPanel styles are in DEFAULT_CSS on the widget itself */

View File

@@ -11,6 +11,82 @@ from textual.widgets import Button, Input, Static
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
@@ -139,8 +215,6 @@ class StatusBar(Static):
def __init__(self) -> None:
super().__init__("")
self._tokens: int = 0
self._budget: int = 0
self._iteration: int = 0
self._max_iterations: int = 0
self._streaming: bool = False
@@ -149,12 +223,6 @@ class StatusBar(Static):
self._stream_tokens: int = 0
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:
"""Update the iteration count display."""
self._iteration = iteration
@@ -200,8 +268,6 @@ class StatusBar(Static):
parts.append(f"{spinner} Thinking")
if self._stream_tokens > 0:
parts.append(f"~{self._stream_tokens:,} tokens")
if self._budget > 0:
parts.append(f"Tokens: ~{self._tokens:,} / {self._budget:,}")
if self._max_iterations > 0:
parts.append(f"Iteration {self._iteration}/{self._max_iterations}")
self.update(Text(" \u2502 ".join(parts), style="dim"))

View File

@@ -44,9 +44,22 @@ if TYPE_CHECKING:
# ---------------------------------------------------------------------------
def render_user_message(content: str) -> Panel:
"""Render a user message as a styled panel."""
return Panel(content, title="You", border_style="cyan", expand=False)
def render_user_message(content: str) -> Text:
"""Render a condensed user prompt as a single styled line.
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:
@@ -223,8 +236,8 @@ def print_success(message: str) -> None:
def print_user_message(content: str) -> None:
"""Print a user message in a styled panel."""
console.print(Panel(content, title="You", border_style="cyan", expand=False))
"""Print a condensed user prompt line."""
console.print(render_user_message(content))
def print_assistant_message(content: str) -> None:

View File

@@ -48,7 +48,6 @@ tools:
- pytest
- ruff
- ls
- cat
- head
- tail
- wc
@@ -56,6 +55,10 @@ tools:
- grep
- find
- echo
- which
- jq
- type
- file
denied_commands:
- rm -rf /
- sudo

View File

@@ -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 |

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -1 +1,21 @@
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)

View File

@@ -21,10 +21,10 @@ from app.utils.display import (
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")
assert isinstance(result, Panel)
assert result.title == "You"
assert isinstance(result, Text)
assert "hello" in result.plain
def test_render_assistant_message_returns_panel(self) -> None:
result = render_assistant_message("response")
@@ -72,7 +72,7 @@ class TestDisplayAdapter:
adapter.write_user_message("hello")
mock_log.write.assert_called_once()
arg = mock_log.write.call_args[0][0]
assert isinstance(arg, Panel)
assert isinstance(arg, Text)
def test_write_tool_call(self) -> None:
mock_log = MagicMock()

View File

@@ -90,6 +90,6 @@ class TestRunCommandTool:
# Create a file in the workspace to verify cwd
(ws / "marker.txt").write_text("found")
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 "found" in result.output