"""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.events import SessionInfo, SessionListUpdated
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="ctrl+x n",
)
async def execute(self, ctx: TuiContext, args: str) -> None:
try:
session = api.create_session(settings.default_profile_id)
except Exception as exc:
ctx.chat_panel.handle_ws_event(
{"type": "error", "message": f"Failed to create session: {exc}"}
)
return
ctx.session_id = session["session_id"]
ctx.profile_id = session.get("profile_id")
ctx.state.set_session_id(session["session_id"])
ctx.status_panel.set_session(session["session_id"])
ctx.status_panel.set_profile(ctx.profile_id or settings.default_profile_id)
ctx.chat_panel.handle_ws_event(
{"type": "status", "content": f"Created session {session['session_id'][:8]}"}
)
await _broadcast_session_list(ctx)
await _reconnect_ws(ctx)
class SessionsCommand(BaseCommand):
meta = CommandMeta(
name="sessions",
aliases=("resume", "continue"),
description="List and switch between sessions.",
keybind="ctrl+x l",
)
async def execute(self, ctx: TuiContext, args: str) -> None:
try:
sessions = api.list_sessions()
except Exception as exc:
ctx.chat_panel.handle_ws_event(
{"type": "error", "message": f"Failed to list sessions: {exc}"}
)
return
lines = ["[b]Sessions[/b]"]
for s in sessions:
sid = s.get("session_id", "")
marker = "● " if sid == ctx.session_id else " "
title = s.get("name", "") or s.get("preview", "")
lines.append(f"{marker}{sid[:8]} {s.get('profile_id', 'unknown')} {title}")
ctx.chat_panel.handle_ws_event({"type": "status", "content": "\n".join(lines)})
await _broadcast_session_list(ctx)
class SwitchCommand(BaseCommand):
meta = CommandMeta(
name="switch",
aliases=(),
description="Switch to another session by id or prefix.",
keybind=None,
)
async def execute(self, ctx: TuiContext, args: str) -> None:
target = args.strip()
if not target:
ctx.chat_panel.handle_ws_event(
{"type": "error", "message": "Usage: /switch <session_id_or_prefix>"}
)
return
try:
session = api.get_session(target)
except Exception:
try:
sessions = api.list_sessions()
matches = [s for s in sessions if s.get("session_id", "").startswith(target)]
if len(matches) == 1:
session = matches[0]
else:
raise Exception("no unique match")
except Exception as exc:
ctx.chat_panel.handle_ws_event(
{"type": "error", "message": f"Session not found: {target} ({exc})"}
)
return
ctx.session_id = session["session_id"]
ctx.profile_id = session.get("profile_id")
ctx.state.set_session_id(session["session_id"])
ctx.status_panel.set_session(session["session_id"])
ctx.status_panel.set_profile(ctx.profile_id or settings.default_profile_id)
ctx.chat_panel.handle_ws_event(
{"type": "status", "content": f"Switched to {session['session_id'][:8]}"}
)
await _broadcast_session_list(ctx)
await _reconnect_ws(ctx)
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
_open_in_editor(str(file_path))
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.
subprocess.Popen([editor, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def _broadcast_session_list(ctx: TuiContext) -> None:
"""Refresh the sessions panel with the latest server state."""
if not ctx.app:
return
try:
sessions = api.list_sessions()
except Exception:
return
info_list = [
SessionInfo(
id=s.get("session_id", ""),
profile_id=s.get("profile_id", "unknown"),
title=s.get("name", "") or s.get("preview", ""),
created_at=s.get("created_at", ""),
)
for s in sessions
]
ctx.app().post_message(SessionListUpdated(info_list, ctx.session_id))
async def _reconnect_ws(ctx: TuiContext) -> None:
"""Close old WebSocket and open a new one for the current session."""
if ctx.ws_client:
await ctx.ws_client.close()
if not ctx.session_id:
return
from clients.terminal.tui.events import ConnectionStatusChanged
from clients.terminal.ws_client import NaviWebSocketClient
new_client = NaviWebSocketClient(ctx.session_id)
ctx.ws_client = new_client
try:
await new_client.connect()
app = ctx.app()
app.run_worker(new_client.receive_loop)
ctx.status_panel.set_connection(True, "")
app.post_message(ConnectionStatusChanged(True, ""))
except Exception as exc:
ctx.status_panel.set_connection(False, str(exc))
ctx.app().post_message(ConnectionStatusChanged(False, str(exc)))