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>
This commit is contained in:
@@ -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,6 +95,14 @@ 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]:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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__)
|
||||||
|
|
||||||
@@ -30,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,
|
||||||
@@ -38,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.
|
||||||
@@ -67,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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -190,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)
|
||||||
@@ -243,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()
|
||||||
@@ -306,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"))
|
||||||
@@ -335,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)
|
||||||
@@ -360,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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user