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:
@@ -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()
|
||||
@@ -190,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)
|
||||
@@ -243,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()
|
||||
@@ -306,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"))
|
||||
@@ -335,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)
|
||||
@@ -360,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:
|
||||
|
||||
Reference in New Issue
Block a user