Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions openhands-cli.spec
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ a = Analysis(
*collect_data_files('openhands.sdk'),
*collect_data_files('openhands.tools'),
*collect_data_files('browser_use'),
*collect_data_files('binaryornot'),
# Include all data files from openhands_cli package
*collect_data_files('openhands_cli'),
# Include package metadata for importlib.metadata
Expand Down
20 changes: 17 additions & 3 deletions openhands_cli/tui/core/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@
DropdownItem(main="/settings - Open settings"),
DropdownItem(main="/confirm - Configure confirmation settings"),
DropdownItem(main="/condense - Condense conversation history"),
DropdownItem(main="/skill - Manage installed skills (install/enable/disable/...)"),
DropdownItem(main="/skills - View loaded skills, hooks, and MCPs"),
DropdownItem(main="/feedback - Send anonymous feedback about CLI"),
DropdownItem(main="/exit - Exit the application"),
]

# Commands that accept subcommands/arguments (prefix-matched)
PREFIX_COMMANDS = {"/skill"}


def get_valid_commands() -> set[str]:
"""Extract valid command names from COMMANDS list.
Expand All @@ -47,15 +51,24 @@ def get_valid_commands() -> set[str]:


def is_valid_command(user_input: str) -> bool:
"""Check if user input is an exact match for a valid command.
"""Check if user input is a valid command.

Supports exact matches (e.g. /help) and prefix matches for commands
that accept subcommands (e.g. /skill install foo).

Args:
user_input: The user's input string

Returns:
True if input exactly matches a valid command, False otherwise
True if input matches a valid command, False otherwise
"""
return user_input in get_valid_commands()
if user_input in get_valid_commands():
return True
# Check prefix commands (e.g. "/skill install foo" starts with "/skill ")
for prefix in PREFIX_COMMANDS:
if user_input.startswith(prefix + " ") or user_input == prefix:
return True
return False


def show_help(scroll_view: VerticalScroll) -> None:
Expand All @@ -77,6 +90,7 @@ def show_help(scroll_view: VerticalScroll) -> None:
[{secondary}]/settings[/{secondary}] - Open settings
[{secondary}]/confirm[/{secondary}] - Configure confirmation settings
[{secondary}]/condense[/{secondary}] - Condense conversation history
[{secondary}]/skill[/{secondary}] - Manage installed skills
[{secondary}]/skills[/{secondary}] - View loaded skills, hooks, and MCPs
[{secondary}]/feedback[/{secondary}] - Send anonymous feedback about CLI
[{secondary}]/exit[/{secondary}] - Exit the application
Expand Down
208 changes: 208 additions & 0 deletions openhands_cli/tui/core/skill_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""Skill lifecycle command handlers for the TUI.

Wraps the SDK's public skill management API (openhands.sdk.skills)
and renders results into the TUI scroll view.
"""

from __future__ import annotations

from textual.containers import VerticalScroll
from textual.widgets import Static

from openhands_cli.theme import OPENHANDS_THEME


_ERR = OPENHANDS_THEME.error
_OK = OPENHANDS_THEME.success
_WARN = OPENHANDS_THEME.warning


def handle_skill_command(scroll_view: VerticalScroll, args: str) -> None:
"""Route /skill <subcommand> to the appropriate handler."""
parts = args.strip().split(None, 1)
subcommand = parts[0] if parts else ""
sub_args = parts[1].strip() if len(parts) > 1 else ""

match subcommand:
case "install":
_skill_install(scroll_view, sub_args)
case "list":
_skill_list(scroll_view)
case "enable":
_skill_enable(scroll_view, sub_args)
case "disable":
_skill_disable(scroll_view, sub_args)
case "uninstall":
_skill_uninstall(scroll_view, sub_args)
case "update":
_skill_update(scroll_view, sub_args)
case _:
_skill_help(scroll_view)


def _mount(scroll_view: VerticalScroll, text: str) -> None:
scroll_view.mount(Static(text, classes="skill-command-message"))
scroll_view.scroll_end(animate=False)


def _skill_help(scroll_view: VerticalScroll) -> None:
s = OPENHANDS_THEME.secondary
p = OPENHANDS_THEME.primary
_mount(
scroll_view,
f"""
[bold {p}]Skill Management[/bold {p}]
[dim]Usage:[/dim] /skill <subcommand>

[{s}]install <source>[/{s}] - Install a skill
[{s}]list[/{s}] - List installed skills
[{s}]enable <name>[/{s}] - Enable a skill
[{s}]disable <name>[/{s}] - Disable a skill
[{s}]uninstall <name>[/{s}] - Uninstall a skill
[{s}]update <name>[/{s}] - Update a skill
""",
)


def _skill_install(scroll_view: VerticalScroll, source: str) -> None:
if not source:
_mount(
scroll_view,
f"[{_ERR}]Usage: /skill install <source>[/]",
)
return
try:
from openhands.sdk.skills import install_skill

info = install_skill(source)
_mount(
scroll_view,
f"[{_OK}]Installed skill '{info.name}'[/]\n"
"Restart your session to load the new skill.",
Comment thread
sjathin marked this conversation as resolved.
)
except Exception as e:
_mount(
scroll_view,
f"[{_ERR}]Install failed: {e}[/]",
)


def _skill_list(scroll_view: VerticalScroll) -> None:
try:
from openhands.sdk.skills import list_installed_skills

skills = list_installed_skills()
if not skills:
_mount(
scroll_view,
"[dim]No installed skills.[/dim]",
)
return
p = OPENHANDS_THEME.primary
lines = [f"\n[bold {p}]Installed Skills ({len(skills)})[/bold {p}]"]
for sk in skills:
status = "✓ enabled" if sk.enabled else "✗ disabled"
style = _OK if sk.enabled else _WARN
desc = f" - {sk.description}" if sk.description else ""
lines.append(f" [{style}]{status}[/] {sk.name}{desc}")
_mount(scroll_view, "\n".join(lines))
except Exception as e:
_mount(scroll_view, f"[{_ERR}]Error: {e}[/]")


def _skill_enable(scroll_view: VerticalScroll, name: str) -> None:
if not name:
_mount(
scroll_view,
f"[{_ERR}]Usage: /skill enable <name>[/]",
)
return
try:
from openhands.sdk.skills import enable_skill

if enable_skill(name):
_mount(
scroll_view,
f"[{_OK}]Enabled skill '{name}'[/]\nRestart your session to apply.",
)
else:
_mount(
scroll_view,
f"[{_WARN}]Skill '{name}' not found.[/]",
)
except Exception as e:
_mount(scroll_view, f"[{_ERR}]Error: {e}[/]")


def _skill_disable(scroll_view: VerticalScroll, name: str) -> None:
if not name:
_mount(
scroll_view,
f"[{_ERR}]Usage: /skill disable <name>[/]",
)
return
try:
from openhands.sdk.skills import disable_skill

if disable_skill(name):
_mount(
scroll_view,
f"[{_OK}]Disabled skill '{name}'[/]\nRestart your session to apply.",
)
else:
_mount(
scroll_view,
f"[{_WARN}]Skill '{name}' not found.[/]",
)
except Exception as e:
_mount(scroll_view, f"[{_ERR}]Error: {e}[/]")


def _skill_uninstall(scroll_view: VerticalScroll, name: str) -> None:
if not name:
_mount(
scroll_view,
f"[{_ERR}]Usage: /skill uninstall <name>[/]",
)
return
try:
from openhands.sdk.skills import uninstall_skill

if uninstall_skill(name):
_mount(
scroll_view,
f"[{_OK}]Uninstalled skill '{name}'[/]",
)
else:
_mount(
scroll_view,
f"[{_WARN}]Skill '{name}' not found.[/]",
)
except Exception as e:
_mount(scroll_view, f"[{_ERR}]Error: {e}[/]")


def _skill_update(scroll_view: VerticalScroll, name: str) -> None:
if not name:
_mount(
scroll_view,
f"[{_ERR}]Usage: /skill update <name>[/]",
)
return
try:
from openhands.sdk.skills import update_skill

info = update_skill(name)
if info:
_mount(
scroll_view,
f"[{_OK}]Updated skill '{info.name}'[/]\n"
"Restart your session to apply.",
)
else:
_mount(
scroll_view,
f"[{_WARN}]Skill '{name}' not found.[/]",
)
except Exception as e:
_mount(scroll_view, f"[{_ERR}]Error: {e}[/]")
7 changes: 7 additions & 0 deletions openhands_cli/tui/widgets/input_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from textual.reactive import var

from openhands_cli.tui.core.commands import show_help, show_skills
from openhands_cli.tui.core.skill_commands import handle_skill_command
from openhands_cli.tui.messages import SlashCommandSubmitted


Expand Down Expand Up @@ -88,6 +89,8 @@ def _on_slash_command_submitted(self, event: SlashCommandSubmitted) -> None:
self._command_condense()
case "skills":
self._command_skills()
case "skill":
self._command_skill(event.args)
case "feedback":
self._command_feedback()
case "exit":
Expand Down Expand Up @@ -159,6 +162,10 @@ def _command_skills(self) -> None:
show_skills(self.scroll_view, self.loaded_resources)
self.scroll_view.scroll_end(animate=False)

def _command_skill(self, args: str) -> None:
"""Handle the /skill command for skill lifecycle management."""
handle_skill_command(self.scroll_view, args)

def _command_feedback(self) -> None:
"""Handle the /feedback command to open feedback form in browser."""
import webbrowser
Expand Down
24 changes: 19 additions & 5 deletions openhands_cli/tui/widgets/user_input/input_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,11 +338,25 @@ def action_submit_textarea(self) -> None:
self.action_toggle_input_mode()
# Use the same submission logic as single-line mode
if is_valid_command(content):
command = content[1:] # Remove leading "/"
self.post_message(SlashCommandSubmitted(command=command))
command, args = self._parse_command(content)
self.post_message(SlashCommandSubmitted(command=command, args=args))
else:
self.post_message(SendMessage(content=content))

@staticmethod
def _parse_command(content: str) -> tuple[str, str]:
"""Parse a slash command into (command, args).

Examples:
"/help" -> ("help", "")
"/skill install x" -> ("skill", "install x")
"""
without_slash = content[1:] # Remove leading "/"
parts = without_slash.split(None, 1)
command = parts[0] if parts else ""
args = parts[1] if len(parts) > 1 else ""
return command, args

def _submit_current_content(self) -> None:
"""Submit current content and clear input.

Expand All @@ -358,9 +372,9 @@ def _submit_current_content(self) -> None:

# Check if this is a valid slash command
if is_valid_command(content):
# Extract command name (without the leading slash)
command = content[1:] # Remove leading "/"
self.post_message(SlashCommandSubmitted(command=command))
# Extract command name and args (without the leading slash)
command, args = self._parse_command(content)
self.post_message(SlashCommandSubmitted(command=command, args=args))
else:
# Regular user input
self.post_message(SendMessage(content=content))
Expand Down
4 changes: 2 additions & 2 deletions openhands_cli/tui/widgets/user_input/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Pydantic models for user input components."""

from enum import Enum
from enum import StrEnum

from pydantic import BaseModel


class CompletionType(str, Enum):
class CompletionType(StrEnum):
"""Type of completion being performed."""

COMMAND = "command"
Expand Down
Loading
Loading