"""Built-in slash commands for Navi Code TUI."""
from __future__ import annotations
import datetime
import os
import subprocess
import sys
from clients.terminal import api
from clients.terminal.config import settings
from clients.terminal.tui.commands.base import BaseCommand, CommandMeta
from clients.terminal.tui.context import TuiContext
from clients.terminal.tui.settings import get_tui_settings
class HelpCommand(BaseCommand):
meta = CommandMeta(
name="help",
aliases=(),
description="Show available slash commands.",
keybind=None,
)
async def execute(self, ctx: TuiContext, args: str) -> None:
from clients.terminal.tui.commands.registry import get_registry
registry = get_registry()
lines = ["[b]Slash commands[/b]"]
for cmd in registry.all():
aliases = f" ({', '.join(cmd.meta.aliases)})" if cmd.meta.aliases else ""
key = f" [{cmd.meta.keybind}]" if cmd.meta.keybind else ""
lines.append(f" /{cmd.meta.name}{aliases}{key} — {cmd.meta.description}")
ctx.chat_panel.handle_ws_event({"type": "status", "content": "\n".join(lines)})
class NewCommand(BaseCommand):
meta = CommandMeta(
name="new",
aliases=("clear",),
description="Start a new session.",
keybind=None,
)
async def execute(self, ctx: TuiContext, args: str) -> None:
app = ctx.app()
if app is None:
return
app.run_worker(app._create_new_session_worker())
class SessionsCommand(BaseCommand):
meta = CommandMeta(
name="sessions",
aliases=("resume", "continue"),
description="Pick a navi_code session to switch to.",
keybind=None,
)
async def execute(self, ctx: TuiContext, args: str) -> None:
app = ctx.app()
if app is None:
return
app._open_sessions_picker()
class SwitchCommand(BaseCommand):
meta = CommandMeta(
name="switch",
aliases=(),
description="Switch to a navi_code session by id/prefix, or open the picker.",
keybind=None,
)
async def execute(self, ctx: TuiContext, args: str) -> None:
app = ctx.app()
if app is None:
return
target = args.strip()
# No argument (or none/ambiguous match) -> open the picker, pre-filtered
# to whatever the user typed so they can finish the choice visually.
if not target:
app._open_sessions_picker()
return
session = self._resolve_session(target)
if session is None:
app._open_sessions_picker(initial_query=target)
return
app.run_worker(app._switch_session_worker(session["session_id"]))
@staticmethod
def _resolve_session(target: str) -> dict | None:
"""Resolve ``target`` to a single navi_code session, or None.
Tries an exact id first (GET /sessions/{id}); if that fails or lands on a
different profile, falls back to prefix-matching the navi_code session
list. Returns the session only when exactly one navi_code match exists.
"""
# Exact id hit.
try:
session = api.get_session(target)
except Exception:
session = None
if session is not None and session.get("profile_id") == settings.default_profile_id:
return session
# Prefix match among navi_code sessions.
try:
sessions = api.list_sessions()
except Exception:
return None
matches = [
s
for s in sessions
if s.get("profile_id") == settings.default_profile_id
and s.get("session_id", "").startswith(target)
]
if len(matches) == 1:
return matches[0]
return None
class ProfileCommand(BaseCommand):
meta = CommandMeta(
name="profile",
aliases=(),
description="Show current session profile.",
keybind=None,
)
async def execute(self, ctx: TuiContext, args: str) -> None:
if not ctx.session_id:
ctx.chat_panel.handle_ws_event({"type": "error", "message": "No active session"})
return
try:
session = api.get_session(ctx.session_id)
except Exception as exc:
ctx.chat_panel.handle_ws_event(
{"type": "error", "message": f"Failed to get session: {exc}"}
)
return
ctx.chat_panel.handle_ws_event(
{
"type": "status",
"content": f"Profile: {session.get('profile_id')}\nSession: {session.get('session_id')}",
}
)
class QuitCommand(BaseCommand):
meta = CommandMeta(
name="quit",
aliases=("exit", "q"),
description="Exit Navi Code.",
keybind="ctrl+x q",
)
async def execute(self, ctx: TuiContext, args: str) -> None:
app = ctx.app()
app.exit()
class ThinkingCommand(BaseCommand):
meta = CommandMeta(
name="thinking",
aliases=(),
description="Toggle thinking block visibility.",
keybind=None,
)
async def execute(self, ctx: TuiContext, args: str) -> None:
settings.show_thinking = not settings.show_thinking
ctx.chat_panel.handle_ws_event(
{
"type": "status",
"content": f"Thinking blocks: {'on' if settings.show_thinking else 'off'}",
}
)
class CompactCommand(BaseCommand):
meta = CommandMeta(
name="compact",
aliases=(),
description="Compact the current session (ask model to summarize context).",
keybind="ctrl+x c",
)
async def execute(self, ctx: TuiContext, args: str) -> None:
if ctx.ws_client:
ctx.ws_client.enqueue("Please summarize and compact our conversation so far.")
class ThemesCommand(BaseCommand):
meta = CommandMeta(
name="themes",
aliases=(),
description="Open the theme picker.",
keybind=None,
)
async def execute(self, ctx: TuiContext, args: str) -> None:
app = ctx.app()
current = getattr(app, "_theme_name", "gnexus-dark")
def on_picked(theme_name: str | None) -> None:
if theme_name is None:
ctx.chat_panel.handle_ws_event(
{"type": "status", "content": "Theme selection cancelled"}
)
return
app = ctx.app()
app._theme_name = theme_name
app.apply_theme()
tui_settings = ctx.settings or get_tui_settings()
tui_settings.theme = theme_name
tui_settings.save()
ctx.chat_panel.handle_ws_event(
{"type": "status", "content": f"Theme set to {theme_name}"}
)
from clients.terminal.tui.screens.theme_picker import ThemePickerScreen
app.push_screen(ThemePickerScreen(current), callback=on_picked)
class MouseCommand(BaseCommand):
meta = CommandMeta(
name="mouse",
aliases=(),
description="Toggle mouse support in the TUI (requires restart).",
keybind=None,
)
async def execute(self, ctx: TuiContext, args: str) -> None:
tui_settings = ctx.settings or get_tui_settings()
if args.strip().lower() in ("on", "true", "1", "yes"):
tui_settings.mouse = True
elif args.strip().lower() in ("off", "false", "0", "no"):
tui_settings.mouse = False
else:
tui_settings.mouse = not tui_settings.mouse
tui_settings.save()
state = "on" if tui_settings.mouse else "off"
ctx.chat_panel.handle_ws_event(
{
"type": "status",
"content": f"Mouse support set to {state}. Restart navi-code to apply.",
}
)
class ExportCommand(BaseCommand):
meta = CommandMeta(
name="export",
aliases=("save",),
description="Export current session to markdown and open $EDITOR.",
keybind=None,
)
async def execute(self, ctx: TuiContext, args: str) -> None:
if not ctx.session_id:
ctx.chat_panel.handle_ws_event(
{"type": "error", "message": "No active session to export"}
)
return
try:
session = api.get_session(ctx.session_id)
except Exception as exc:
ctx.chat_panel.handle_ws_event(
{"type": "error", "message": f"Failed to get session: {exc}"}
)
return
short_id = ctx.session_id[:8]
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{short_id}_{timestamp}.md"
exports_dir = ctx.state.state_dir / "exports"
exports_dir.mkdir(parents=True, exist_ok=True)
file_path = exports_dir / filename
try:
file_path.write_text(_render_export_markdown(session), encoding="utf-8")
except OSError as exc:
ctx.chat_panel.handle_ws_event(
{"type": "error", "message": f"Failed to write export: {exc}"}
)
return
try:
_open_in_editor(str(file_path))
except OSError as exc:
ctx.chat_panel.handle_ws_event(
{
"type": "error",
"message": f"Failed to open editor for {file_path}: {exc}",
}
)
return
ctx.chat_panel.handle_ws_event({"type": "status", "content": f"Exported to {file_path}"})
def _render_export_markdown(session: dict) -> str:
lines: list[str] = []
session_id = session.get("session_id", "unknown")
profile_id = session.get("profile_id", "unknown")
created = session.get("created_at", "")
lines.append(f"# Navi Code Export — {session_id[:8]}")
lines.append("")
lines.append(f"- **Profile:** {profile_id}")
lines.append(f"- **Session:** {session_id}")
if created:
lines.append(f"- **Created:** {created}")
lines.append("")
messages = session.get("messages", [])
for msg in messages:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if not content:
continue
heading = role.capitalize()
lines.append(f"## {heading}")
lines.append("")
lines.append(content)
lines.append("")
return "\n".join(lines)
def _open_in_editor(path: str) -> None:
editor = os.environ.get("EDITOR")
if not editor:
editor = "notepad" if sys.platform == "win32" else "vi"
# Detach so the TUI is not blocked while the editor runs.
try:
subprocess.Popen([editor, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as exc:
raise OSError(f"cannot start editor '{editor}': {exc}") from exc