Files
python-sdk/pkg/hanzo-dev/src/hanzo_dev/command_palette.py
T
zeekay 7b4ab58c38 feat: hanzo-dev Python TUI + LSP + hooks + sandbox + vim + git + multi-auth + full parity
Rename hanzo-repl → hanzo-dev to match @hanzo/dev (Rust/TS).

New packages:
- hanzo-lsp: async LSP client (diagnostics, go-to-def, references, context enrichment)
- hanzo-hooks: pre/post tool use hooks (shell scripts, exit-code deny)
- hanzo-sandbox: container detection, filesystem isolation, Linux unshare

hanzo-dev TUI:
- Vim keybindings (Normal/Insert/Visual/Command modes, 37 tests)
- Git slash commands (/branch /commit /worktree /diff /stash, 13 tests)
- All slash commands at parity: /model /permissions /clear /resume /memory
  /init /export /session /plan /solve /code /auto /fast /remote-control
- Multi-provider auth: Anthropic OAuth, OpenAI device code, Hanzo PKCE, auto-detect

Auth:
- login_with_anthropic() — PKCE via console.anthropic.com
- login_with_openai() — device code flow via auth.openai.com
- login_auto() — env var detection + saved credential fallback
- OAuthCredentialStore supports provider keys (hanzo/anthropic/openai)

Parity test suite (38 tests): SSE, MCP normalization, permissions, compaction,
token usage, PKCE RFC vector, config, backoff, session roundtrip, hooks.

348 tests total, 0 failures across Python + Rust + Universe E2E.
2026-04-01 10:41:46 -07:00

289 lines
8.2 KiB
Python

"""Command palette widget for MCP tool selection."""
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from textual import events
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import Input, Label, ListItem, ListView, Static
@dataclass
class Command:
"""Represents a command/tool."""
name: str
description: str
category: str
icon: str = "⚡"
usage_count: int = 0
parameters: Optional[Dict[str, Any]] = None
class CommandSelected(Message):
"""Message sent when a command is selected."""
def __init__(self, command: Command) -> None:
self.command = command
super().__init__()
class CommandItem(ListItem):
"""A command item in the list."""
def __init__(self, command: Command) -> None:
super().__init__()
self.command = command
def compose(self) -> ComposeResult:
"""Create child widgets."""
with Horizontal(classes="command-item"):
# Icon and category
yield Static(
f"{self.command.icon} [{self.command.category}]", classes="command-icon"
)
# Name and description
with Vertical(classes="command-info"):
yield Static(self.command.name, classes="command-name")
yield Static(
self.command.description[:60] + "...", classes="command-desc"
)
class CommandPalette(Vertical):
"""Command palette overlay widget."""
CSS = """
CommandPalette {
layer: overlay;
width: 80%;
height: 60%;
background: $panel;
border: thick $primary;
padding: 1;
align: center middle;
offset: 10% 20%;
}
#palette-input {
dock: top;
height: 3;
margin-bottom: 1;
background: $background;
border: tall $secondary;
padding: 0 1;
}
#palette-list {
height: 1fr;
background: $background;
border: none;
overflow-y: auto;
}
.command-item {
padding: 0 1;
height: 3;
}
.command-item:hover {
background: $boost;
}
.command-icon {
width: 20;
color: $primary;
}
.command-info {
width: 1fr;
}
.command-name {
text-style: bold;
}
.command-desc {
color: $text-muted;
text-style: italic;
}
#palette-hint {
dock: bottom;
height: 1;
color: $text-muted;
text-align: center;
margin-top: 1;
}
"""
commands = reactive([], always_update=True)
filtered_commands = reactive([], always_update=True)
def __init__(self, tools: Dict[str, Any]) -> None:
super().__init__(id="command-palette")
self.all_commands = self._build_commands(tools)
self.commands = self.all_commands
self.filtered_commands = self.all_commands
def _build_commands(self, tools: Dict[str, Any]) -> List[Command]:
"""Build command list from MCP tools."""
commands = []
# Tool categories with icons
category_icons = {
"file": "📁",
"search": "🔍",
"shell": "💻",
"agent": "🤖",
"llm": "🧠",
"editor": "✏️",
"todo": "✅",
"vector": "🔗",
"database": "🗄️",
}
for name, tool in sorted(tools.items()):
# Determine category from tool module
category = "general"
if hasattr(tool, "__module__"):
parts = tool.__module__.split(".")
if len(parts) > 2:
category = parts[-1]
icon = category_icons.get(category, "⚡")
commands.append(
Command(
name=name,
description=tool.description,
category=category,
icon=icon,
parameters=(
tool.get_schema() if hasattr(tool, "get_schema") else None
),
)
)
# Add some special commands
commands.extend(
[
Command("clear", "Clear the chat history", "system", "🗑️"),
Command("help", "Show help and shortcuts", "system", "❓"),
Command("model", "Change AI model", "system", "🔄"),
Command("theme", "Change theme", "system", "🎨"),
]
)
return commands
def compose(self) -> ComposeResult:
"""Create child widgets."""
yield Input(
placeholder="Search commands... (fuzzy matching enabled)",
id="palette-input",
)
yield ListView(id="palette-list")
yield Label(
"↑↓ Navigate · ⏎ Select · esc Close · ⇥ Complete", id="palette-hint"
)
def on_mount(self) -> None:
"""Focus input when mounted."""
self.query_one("#palette-input", Input).focus()
self._update_list()
def _fuzzy_match(self, query: str, text: str) -> bool:
"""Simple fuzzy matching."""
query = query.lower()
text = text.lower()
# Direct substring match
if query in text:
return True
# Character-by-character fuzzy match
query_idx = 0
for char in text:
if query_idx < len(query) and char == query[query_idx]:
query_idx += 1
return query_idx == len(query)
def on_input_changed(self, event: Input.Changed) -> None:
"""Filter commands as user types."""
query = event.value.strip()
if not query:
self.filtered_commands = self.all_commands
else:
# Fuzzy filter
filtered = []
for cmd in self.all_commands:
if (
self._fuzzy_match(query, cmd.name)
or self._fuzzy_match(query, cmd.description)
or self._fuzzy_match(query, cmd.category)
):
filtered.append(cmd)
# Sort by relevance (prefer name matches)
filtered.sort(
key=lambda c: (
not c.name.lower().startswith(query.lower()),
query.lower() not in c.name.lower(),
-c.usage_count,
)
)
self.filtered_commands = filtered
self._update_list()
def _update_list(self) -> None:
"""Update the command list."""
list_view = self.query_one("#palette-list", ListView)
list_view.clear()
for cmd in self.filtered_commands[:20]: # Limit to 20 items
list_view.append(CommandItem(cmd))
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle command selection."""
if isinstance(event.item, CommandItem):
self.post_message(CommandSelected(event.item.command))
self.remove()
def on_key(self, event: events.Key) -> None:
"""Handle keyboard shortcuts."""
if event.key == "escape":
self.remove()
elif event.key == "tab":
# Auto-complete to first match
if self.filtered_commands:
input_widget = self.query_one("#palette-input", Input)
input_widget.value = self.filtered_commands[0].name
elif event.key == "enter":
# Execute first match or AI complete
input_widget = self.query_one("#palette-input", Input)
value = input_widget.value.strip()
if self.filtered_commands:
# Use first match
self.post_message(CommandSelected(self.filtered_commands[0]))
elif value:
# Try AI completion
ai_cmd = Command(
name="ai_complete",
description=f"AI: {value}",
category="ai",
icon="✨",
)
self.post_message(CommandSelected(ai_cmd))
self.remove()