From 0b3a816001b53fa8acca223522ae5069be07bd61 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Tue, 17 Feb 2026 13:44:16 +0100 Subject: [PATCH 1/5] feat: commands --- Makefile | 14 +-- codeai/commands/__init__.py | 120 +++++++++++++++++++++ codeai/commands/about.py | 24 +++++ codeai/commands/agents.py | 101 ++++++++++++++++++ codeai/commands/browser.py | 26 +++++ codeai/commands/clear.py | 42 ++++++++ codeai/commands/cls.py | 23 +++++ codeai/commands/codemode_toggle.py | 66 ++++++++++++ codeai/commands/context.py | 44 ++++++++ codeai/commands/context_export.py | 64 ++++++++++++ codeai/commands/exit.py | 36 +++++++ codeai/commands/gif.py | 24 +++++ codeai/commands/help.py | 69 +++++++++++++ codeai/commands/jupyter.py | 33 ++++++ codeai/commands/mcp_servers.py | 61 +++++++++++ codeai/commands/rain.py | 24 +++++ codeai/commands/skills.py | 73 +++++++++++++ codeai/commands/status.py | 56 ++++++++++ codeai/commands/suggestions.py | 93 +++++++++++++++++ codeai/commands/tools.py | 57 ++++++++++ codeai/commands/tools_last.py | 78 ++++++++++++++ codeai/tux.py | 161 +---------------------------- 22 files changed, 1127 insertions(+), 162 deletions(-) create mode 100644 codeai/commands/__init__.py create mode 100644 codeai/commands/about.py create mode 100644 codeai/commands/agents.py create mode 100644 codeai/commands/browser.py create mode 100644 codeai/commands/clear.py create mode 100644 codeai/commands/cls.py create mode 100644 codeai/commands/codemode_toggle.py create mode 100644 codeai/commands/context.py create mode 100644 codeai/commands/context_export.py create mode 100644 codeai/commands/exit.py create mode 100644 codeai/commands/gif.py create mode 100644 codeai/commands/help.py create mode 100644 codeai/commands/jupyter.py create mode 100644 codeai/commands/mcp_servers.py create mode 100644 codeai/commands/rain.py create mode 100644 codeai/commands/skills.py create mode 100644 codeai/commands/status.py create mode 100644 codeai/commands/suggestions.py create mode 100644 codeai/commands/tools.py create mode 100644 codeai/commands/tools_last.py diff --git a/Makefile b/Makefile index d49623e..2481f48 100644 --- a/Makefile +++ b/Makefile @@ -75,12 +75,14 @@ codeai-financial: # codeai-financial ALPHA_VANTAGE_API_KEY must be set in env AWS_DEFAULT_REGION=${DATALAYER_BEDROCK_AWS_DEFAULT_REGION} \ codeai --eggs --agentspec-id datalayer-ai/financial -########### -# Prompts # -########### +########## +# Demo # +########## + # List files located in the sales-data folder of my Google Drive account (eric@datalayer.io). # Aggregate all CSV files located in the sales-data folder of my Google Drive account (eric@datalayer.io) into a single file named sales_21-25.csv, and save this aggregated file in the sales-data directory of the echarles/openteams-codemode-demo repository. -codeai-openteams-demo: # codeai-openteams-demo + +codeai-demo: # codeai-demo @AWS_ACCESS_KEY_ID=${DATALAYER_BEDROCK_AWS_ACCESS_KEY_ID} \ AWS_SECRET_ACCESS_KEY=${DATALAYER_BEDROCK_AWS_SECRET_ACCESS_KEY} \ AWS_DEFAULT_REGION=${DATALAYER_BEDROCK_AWS_DEFAULT_REGION} \ @@ -88,10 +90,10 @@ codeai-openteams-demo: # codeai-openteams-demo GOOGLE_OAUTH_CLIENT_SECRET=${OPENTEAMS_DEMO_GOOGLE_CLIENT_SECRET} \ codeai --eggs --agentspec-id codemode-paper/information-routing -codeai-openteams-demo-nocodemode: # codeai-openteams-demo-codemode +codeai-demo-nocodemode: # codeai-demo-nocodemode @AWS_ACCESS_KEY_ID=${DATALAYER_BEDROCK_AWS_ACCESS_KEY_ID} \ AWS_SECRET_ACCESS_KEY=${DATALAYER_BEDROCK_AWS_SECRET_ACCESS_KEY} \ AWS_DEFAULT_REGION=${DATALAYER_BEDROCK_AWS_DEFAULT_REGION} \ GOOGLE_OAUTH_CLIENT_ID=${OPENTEAMS_DEMO_GOOGLE_CLIENT_ID} \ GOOGLE_OAUTH_CLIENT_SECRET=${OPENTEAMS_DEMO_GOOGLE_CLIENT_SECRET} \ - codeai --eggs --agentspec-id codemode-paper/information-routing --no-codemode \ No newline at end of file + codeai --eggs --agentspec-id codemode-paper/information-routing --no-codemode diff --git a/codeai/commands/__init__.py b/codeai/commands/__init__.py new file mode 100644 index 0000000..dfe0fd7 --- /dev/null +++ b/codeai/commands/__init__.py @@ -0,0 +1,120 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Commands package - one file per slash command. + +Each command module exports: + NAME: str - primary command name + ALIASES: list[str] - alternative names + DESCRIPTION: str - help text + SHORTCUT: Optional[str] - keyboard shortcut (e.g., "escape x") + execute(tux) -> Optional[str] - async handler, returns optional next prompt +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + + +@dataclass +class SlashCommand: + """Definition of a slash command.""" + name: str + aliases: list[str] = field(default_factory=list) + description: str = "" + handler: Optional[Callable] = None + shortcut: Optional[str] = None # e.g., "escape x" for Esc, X + + +def build_commands( + tux: "CodeAITux", + eggs: bool = False, + jupyter_url: Optional[str] = None, +) -> dict[str, SlashCommand]: + """Build all slash commands, binding handlers to the tux instance. + + Args: + tux: The CodeAITux instance. + eggs: Enable Easter egg commands. + jupyter_url: Jupyter URL (enables /jupyter command when set). + + Returns: + Dict mapping command names (including aliases) to SlashCommand instances. + """ + from . import ( + context, + clear, + help, + status, + exit, + agents, + tools, + mcp_servers, + skills, + codemode_toggle, + context_export, + tools_last, + cls, + browser, + suggestions, + ) + + # Core commands always registered + modules = [ + context, + clear, + help, + status, + exit, + agents, + tools, + mcp_servers, + skills, + codemode_toggle, + context_export, + tools_last, + cls, + browser, + suggestions, + ] + + # Conditionally add egg commands + if eggs: + from . import rain, about, gif + modules.extend([rain, about, gif]) + + # Conditionally add jupyter command + if jupyter_url: + from . import jupyter + modules.append(jupyter) + + commands: dict[str, SlashCommand] = {} + + for mod in modules: + # Create handler closure that captures tux + handler = _make_handler(mod.execute, tux) + + cmd = SlashCommand( + name=mod.NAME, + aliases=getattr(mod, "ALIASES", []), + description=getattr(mod, "DESCRIPTION", ""), + handler=handler, + shortcut=getattr(mod, "SHORTCUT", None), + ) + commands[cmd.name] = cmd + for alias in cmd.aliases: + commands[alias] = cmd + + return commands + + +def _make_handler(execute_fn: Callable, tux: "CodeAITux") -> Callable: + """Create a handler closure that passes tux to the execute function.""" + async def handler(): + return await execute_fn(tux) + return handler diff --git a/codeai/commands/about.py b/codeai/commands/about.py new file mode 100644 index 0000000..401fa8d --- /dev/null +++ b/codeai/commands/about.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /about - About Datalayer animation (Easter egg).""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "about" +ALIASES: list[str] = [] +DESCRIPTION = "About Datalayer" +SHORTCUT = "escape l" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Display About Datalayer animation.""" + from ..animations import about_animation + await about_animation(tux.console) + return None diff --git a/codeai/commands/agents.py b/codeai/commands/agents.py new file mode 100644 index 0000000..64097b1 --- /dev/null +++ b/codeai/commands/agents.py @@ -0,0 +1,101 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /agents - List available agents.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "agents" +ALIASES: list[str] = [] +DESCRIPTION = "List available agents on the server" +SHORTCUT = "escape a" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """List available agents with detailed information.""" + from ..tux import STYLE_PRIMARY, STYLE_ACCENT, STYLE_MUTED + + try: + async with httpx.AsyncClient() as client: + url = f"{tux.server_url}/api/v1/agents" + response = await client.get(url, timeout=10.0) + response.raise_for_status() + data = response.json() + except Exception as e: + tux.console.print(f"[red]Error fetching agents: {e}[/red]") + return None + + agents_list = data.get("agents", []) + + if not agents_list: + tux.console.print("No agents available", style=STYLE_MUTED) + return None + + tux.console.print() + tux.console.print(f"● Available Agents ({len(agents_list)}):", style=STYLE_PRIMARY) + tux.console.print() + + for agent in agents_list: + agent_id = agent.get("id", "unknown") + name = agent.get("name", "Unknown") + description = agent.get("description", "") + model = agent.get("model", "unknown") + status = agent.get("status", "unknown") + toolsets = agent.get("toolsets", {}) + + # Status indicator + status_icon = "[green]●[/green]" if status == "running" else "[red]○[/red]" + tux.console.print(f" {status_icon} {name} ({agent_id})", style=STYLE_ACCENT) + + # Description + if description: + desc = description[:60] + "..." if len(description) > 60 else description + tux.console.print(f" {desc}", style=STYLE_MUTED) + + # Model + tux.console.print(f" Model: {model}", style=STYLE_MUTED) + + # Codemode + codemode = toolsets.get("codemode", False) + codemode_text = "enabled" if codemode else "disabled" + codemode_style = STYLE_ACCENT if codemode else STYLE_MUTED + tux.console.print(f" Codemode: ", style=STYLE_MUTED, end="") + tux.console.print(codemode_text, style=codemode_style) + + # MCP Servers + mcp_servers = toolsets.get("mcp_servers", []) + if mcp_servers: + mcp_text = ", ".join(mcp_servers[:5]) + if len(mcp_servers) > 5: + mcp_text += f" (+{len(mcp_servers) - 5} more)" + tux.console.print(f" MCP Servers: {mcp_text}", style=STYLE_MUTED) + + # Tools count + tools_count = toolsets.get("tools_count", 0) + if tools_count > 0: + tux.console.print(f" Tools: {tools_count}", style=STYLE_MUTED) + + # Skills + skills = toolsets.get("skills", []) + if skills: + skill_names = [] + for s in skills[:3]: + if isinstance(s, dict): + skill_names.append(s.get("name", "?")) + else: + skill_names.append(str(s)) + skills_text = ", ".join(skill_names) + if len(skills) > 3: + skills_text += f" (+{len(skills) - 3} more)" + tux.console.print(f" Skills: {skills_text}", style=STYLE_MUTED) + + tux.console.print() + return None diff --git a/codeai/commands/browser.py b/codeai/commands/browser.py new file mode 100644 index 0000000..0e097aa --- /dev/null +++ b/codeai/commands/browser.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /browser - Open the Agent chat UI in the browser.""" + +from __future__ import annotations + +import webbrowser +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "browser" +ALIASES: list[str] = [] +DESCRIPTION = "Open the Agent chat UI in your browser" +SHORTCUT = "escape w" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Open the Agent chat web UI in the default browser.""" + url = f"{tux.server_url}/static/agent.html?agentId={tux.agent_id}" + tux.console.print(f" Opening [bold cyan]{url}[/bold cyan]") + webbrowser.open(url) + return None diff --git a/codeai/commands/clear.py b/codeai/commands/clear.py new file mode 100644 index 0000000..f50b6c3 --- /dev/null +++ b/codeai/commands/clear.py @@ -0,0 +1,42 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /clear - Clear conversation history.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "clear" +ALIASES = ["reset", "new"] +DESCRIPTION = "Clear conversation history and free up context" +SHORTCUT = "escape c" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Clear conversation history.""" + from ..tux import STYLE_PRIMARY, SessionStats + + try: + async with httpx.AsyncClient() as client: + url = f"{tux.server_url}/api/v1/configure/agents/{tux.agent_id}/context-details/reset" + response = await client.post(url, timeout=10.0) + response.raise_for_status() + except Exception as e: + tux.console.print(f"[red]Error clearing context: {e}[/red]") + return None + + # Reset local AG-UI client to clear conversation history + if tux._agui_client is not None: + await tux._agui_client.disconnect() + tux._agui_client = None + + tux.stats = SessionStats() + tux.console.print("● Conversation cleared. Starting fresh.", style=STYLE_PRIMARY) + return None diff --git a/codeai/commands/cls.py b/codeai/commands/cls.py new file mode 100644 index 0000000..271be29 --- /dev/null +++ b/codeai/commands/cls.py @@ -0,0 +1,23 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /cls - Clear the screen.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "cls" +ALIASES: list[str] = [] +DESCRIPTION = "Clear the screen" +SHORTCUT: str | None = None + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Clear the screen.""" + tux.console.clear() + return None diff --git a/codeai/commands/codemode_toggle.py b/codeai/commands/codemode_toggle.py new file mode 100644 index 0000000..9ad88fe --- /dev/null +++ b/codeai/commands/codemode_toggle.py @@ -0,0 +1,66 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /codemode-toggle - Toggle codemode on/off.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "codemode-toggle" +ALIASES = ["codemode"] +DESCRIPTION = "Toggle codemode on/off for enhanced code capabilities" +SHORTCUT = "escape o" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Toggle codemode on/off.""" + from ..tux import STYLE_ACCENT, STYLE_MUTED, STYLE_WARNING + + # First get current status + try: + async with httpx.AsyncClient() as client: + status_url = f"{tux.server_url}/api/v1/configure/codemode-status" + status_response = await client.get(status_url, timeout=10.0) + status_response.raise_for_status() + current_status = status_response.json() + except Exception as e: + tux.console.print(f"[red]Error checking codemode status: {e}[/red]") + return None + + current_enabled = current_status.get("enabled", False) + new_enabled = not current_enabled + + # Toggle to opposite state + try: + async with httpx.AsyncClient() as client: + url = f"{tux.server_url}/api/v1/configure/codemode/toggle" + response = await client.post( + url, + json={"enabled": new_enabled}, + timeout=10.0, + ) + response.raise_for_status() + data = response.json() + except Exception as e: + tux.console.print(f"[red]Error toggling codemode: {e}[/red]") + return None + + enabled = data.get("enabled", False) + + tux.console.print() + if enabled: + tux.console.print("● Codemode enabled", style=STYLE_ACCENT) + tux.console.print(" Enhanced code capabilities are now active.", style=STYLE_MUTED) + tux.console.print(" Use /skills to see available skills.", style=STYLE_MUTED) + else: + tux.console.print("● Codemode disabled", style=STYLE_WARNING) + tux.console.print(" Standard mode is now active.", style=STYLE_MUTED) + tux.console.print() + return None diff --git a/codeai/commands/context.py b/codeai/commands/context.py new file mode 100644 index 0000000..9ad745c --- /dev/null +++ b/codeai/commands/context.py @@ -0,0 +1,44 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /context - Visualize current context usage.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import httpx +from rich.text import Text + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "context" +ALIASES: list[str] = [] +DESCRIPTION = "Visualize current context usage as a colored grid" +SHORTCUT = "escape x" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Display context usage visualization.""" + try: + async with httpx.AsyncClient() as client: + url = f"{tux.server_url}/api/v1/configure/agents/{tux.agent_id}/context-table?show_context=false" + response = await client.get(url, timeout=10.0) + response.raise_for_status() + data = response.json() + except Exception as e: + tux.console.print(f"[red]Error fetching context: {e}[/red]") + return None + + if data.get("error"): + tux.console.print(f"[red]{data.get('error')}[/red]") + return None + + table_text = data.get("table", "").rstrip() + if table_text: + tux.console.print(Text.from_ansi(table_text)) + else: + tux.console.print("[red]No table content returned.[/red]") + return None diff --git a/codeai/commands/context_export.py b/codeai/commands/context_export.py new file mode 100644 index 0000000..2b1d0c3 --- /dev/null +++ b/codeai/commands/context_export.py @@ -0,0 +1,64 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /context-export - Export current context to CSV.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "context-export" +ALIASES = ["export"] +DESCRIPTION = "Export the current context to a CSV file" +SHORTCUT = "escape e" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Export the current context to a CSV file.""" + from ..tux import STYLE_ACCENT, STYLE_MUTED + + try: + async with httpx.AsyncClient() as client: + url = f"{tux.server_url}/api/v1/configure/agents/{tux.agent_id}/context-export" + response = await client.get(url, timeout=10.0) + response.raise_for_status() + data = response.json() + except Exception as e: + tux.console.print(f"[red]Error fetching context: {e}[/red]") + return None + + if data.get("error"): + tux.console.print(f"[red]{data.get('error')}[/red]") + return None + + filename = data.get("filename", "codeai_context.csv") + csv_content = data.get("csv", "") + + if not csv_content: + tux.console.print("[red]No CSV content returned.[/red]") + return None + + try: + with open(filename, "w", newline="") as csvfile: + csvfile.write(csv_content) + + tools_count = data.get("toolsCount", 0) + messages_count = data.get("messagesCount", 0) + + tux.console.print() + tux.console.print(f"● Context exported to {filename}", style=STYLE_ACCENT) + if tools_count or messages_count: + tux.console.print( + f" Contains {tools_count} tools and {messages_count} messages", + style=STYLE_MUTED, + ) + tux.console.print() + except IOError as e: + tux.console.print(f"[red]Error writing file: {e}[/red]") + return None diff --git a/codeai/commands/exit.py b/codeai/commands/exit.py new file mode 100644 index 0000000..dff9d08 --- /dev/null +++ b/codeai/commands/exit.py @@ -0,0 +1,36 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /exit - Exit Code AI.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "exit" +ALIASES = ["quit", "q"] +DESCRIPTION = "Exit Code AI" +SHORTCUT = "escape q" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Exit the application.""" + from ..tux import STYLE_ACCENT, STYLE_MUTED + from ..banner import GOODBYE_MESSAGE + + tux.running = False + + # Clean up AG-UI client + if tux._agui_client is not None: + await tux._agui_client.disconnect() + tux._agui_client = None + + tux.console.print() + tux.console.print(GOODBYE_MESSAGE, style=STYLE_ACCENT) + tux.console.print(" [link=https://datalayer.ai]https://datalayer.ai[/link]", style=STYLE_MUTED) + tux.console.print() + return None diff --git a/codeai/commands/gif.py b/codeai/commands/gif.py new file mode 100644 index 0000000..d2f4e18 --- /dev/null +++ b/codeai/commands/gif.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /gif - Black hole spinning animation (Easter egg).""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "gif" +ALIASES: list[str] = [] +DESCRIPTION = "Black hole spinning animation" +SHORTCUT = "escape g" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Display black hole spinning animation (5 seconds).""" + from ..animations import gif_animation + await gif_animation(tux.console) + return None diff --git a/codeai/commands/help.py b/codeai/commands/help.py new file mode 100644 index 0000000..2fa6af8 --- /dev/null +++ b/codeai/commands/help.py @@ -0,0 +1,69 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /help - Show available commands.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "help" +ALIASES = ["?"] +DESCRIPTION = "Show available commands" +SHORTCUT = "escape h" + + +def _format_shortcut(shortcut: Optional[str]) -> str: + """Format a shortcut string for display.""" + if not shortcut: + return "" + if shortcut.startswith("escape "): + return f"Esc,{shortcut[7:].upper()}" + if shortcut.startswith("c-"): + return f"Ctrl+{shortcut[2:].upper()}" + return shortcut + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Show available commands.""" + from ..tux import STYLE_WHITE, STYLE_PRIMARY, STYLE_MUTED, STYLE_SECONDARY + + tux.console.print() + tux.console.print("Available Commands:", style=STYLE_WHITE) + tux.console.print() + + shown: set[str] = set() + for name, cmd in sorted(tux.commands.items()): + if cmd.name in shown: + continue + shown.add(cmd.name) + + # Build command name with aliases + aliases_str = "" + if cmd.aliases: + aliases_str = f" ({', '.join(cmd.aliases)})" + + # Build shortcut indicator + shortcut_str = "" + if cmd.shortcut: + shortcut_str = f" [{_format_shortcut(cmd.shortcut)}]" + + cmd_display = f"/{cmd.name}{aliases_str}" + tux.console.print(f" {cmd_display}", style=STYLE_PRIMARY, end="") + + # Calculate padding for alignment + padding_len = max(1, 22 - len(cmd_display)) + tux.console.print(" " * padding_len, end="") + tux.console.print(cmd.description, style=STYLE_MUTED, end="") + + if shortcut_str: + tux.console.print(f" {shortcut_str}", style=STYLE_SECONDARY) + else: + tux.console.print() + + tux.console.print() + return None diff --git a/codeai/commands/jupyter.py b/codeai/commands/jupyter.py new file mode 100644 index 0000000..4551cf5 --- /dev/null +++ b/codeai/commands/jupyter.py @@ -0,0 +1,33 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /jupyter - Open the Jupyter server in the browser.""" + +from __future__ import annotations + +import webbrowser +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "jupyter" +ALIASES: list[str] = [] +DESCRIPTION = "Open the Jupyter server in your browser" +SHORTCUT = "escape j" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Open the Jupyter server API page in the default browser.""" + if tux.jupyter_url: + # Append /api so the browser lands on the Jupyter REST API root + sep = "&" if "?" in tux.jupyter_url else "?" + base = tux.jupyter_url.split("?")[0].rstrip("/") + "/api" + query = tux.jupyter_url.split("?")[1] if "?" in tux.jupyter_url else None + url = f"{base}?{query}" if query else base + tux.console.print(f" Opening [bold cyan]{url}[/bold cyan]") + webbrowser.open(url) + else: + tux.console.print(" [yellow]No Jupyter server available.[/yellow]") + return None diff --git a/codeai/commands/mcp_servers.py b/codeai/commands/mcp_servers.py new file mode 100644 index 0000000..2d3c195 --- /dev/null +++ b/codeai/commands/mcp_servers.py @@ -0,0 +1,61 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /mcp-servers - List MCP servers and their status.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "mcp-servers" +ALIASES = ["mcp"] +DESCRIPTION = "List MCP servers and their status" +SHORTCUT = "escape m" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """List MCP servers and their status.""" + from ..tux import STYLE_PRIMARY, STYLE_ACCENT, STYLE_MUTED + + try: + async with httpx.AsyncClient() as client: + url = f"{tux.server_url}/api/v1/mcp/servers" + response = await client.get(url, timeout=10.0) + response.raise_for_status() + servers = response.json() + except Exception as e: + tux.console.print(f"[red]Error fetching MCP servers: {e}[/red]") + return None + + if not servers: + tux.console.print("No MCP servers running", style=STYLE_MUTED) + return None + + tux.console.print() + tux.console.print(f"● MCP Servers ({len(servers)}):", style=STYLE_PRIMARY) + tux.console.print() + + for server in servers: + server_id = server.get("id", "Unknown") + server_name = server.get("name", server_id) + is_available = server.get("isAvailable", False) + tools = server.get("tools", []) + + status = "[green]●[/green]" if is_available else "[red]●[/red]" + tux.console.print(f" {status} {server_name}", style=STYLE_ACCENT) + + if tools: + tool_names = [t.get("name", "?") for t in tools[:5]] + tools_str = ", ".join(tool_names) + if len(tools) > 5: + tools_str += f" (+{len(tools) - 5} more)" + tux.console.print(f" Tools: {tools_str}", style=STYLE_MUTED) + + tux.console.print() + return None diff --git a/codeai/commands/rain.py b/codeai/commands/rain.py new file mode 100644 index 0000000..6da132c --- /dev/null +++ b/codeai/commands/rain.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /rain - Matrix rain animation (Easter egg).""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "rain" +ALIASES: list[str] = [] +DESCRIPTION = "Matrix rain animation" +SHORTCUT = "escape r" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Display Matrix rain animation (5 seconds).""" + from ..animations import rain_animation + await rain_animation(tux.console) + return None diff --git a/codeai/commands/skills.py b/codeai/commands/skills.py new file mode 100644 index 0000000..f4b2062 --- /dev/null +++ b/codeai/commands/skills.py @@ -0,0 +1,73 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /skills - List available skills.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "skills" +ALIASES: list[str] = [] +DESCRIPTION = "List available skills (requires codemode enabled)" +SHORTCUT = "escape k" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """List available skills (requires codemode enabled).""" + from ..tux import STYLE_PRIMARY, STYLE_ACCENT, STYLE_MUTED, STYLE_WARNING + + # First check if codemode is enabled + try: + async with httpx.AsyncClient() as client: + url = f"{tux.server_url}/api/v1/configure/codemode-status" + response = await client.get(url, timeout=10.0) + response.raise_for_status() + status_data = response.json() + except Exception as e: + tux.console.print(f"[red]Error checking codemode status: {e}[/red]") + return None + + codemode_enabled = status_data.get("enabled", False) + + if not codemode_enabled: + tux.console.print() + tux.console.print("● Codemode is disabled", style=STYLE_WARNING) + tux.console.print(" Skills are only available when codemode is enabled.", style=STYLE_MUTED) + tux.console.print(" Use /codemode-toggle to enable it.", style=STYLE_MUTED) + tux.console.print() + return None + + # Get skills from codemode status (it includes available_skills) + skills = status_data.get("available_skills", []) + active_skills = {s.get("name") for s in status_data.get("skills", [])} + + if not skills: + tux.console.print("No skills available", style=STYLE_MUTED) + return None + + tux.console.print() + tux.console.print(f"● Available Skills ({len(skills)}):", style=STYLE_PRIMARY) + tux.console.print() + + for skill in skills: + skill_name = skill.get("name", "Unknown") + skill_desc = skill.get("description", "") + is_active = skill_name in active_skills + # Truncate description if too long + if len(skill_desc) > 60: + skill_desc = skill_desc[:57] + "..." + # Show active status + status_icon = "[green]●[/green]" if is_active else "○" + tux.console.print(f" {status_icon} {skill_name}", style=STYLE_ACCENT if is_active else STYLE_MUTED) + if skill_desc: + tux.console.print(f" {skill_desc}", style=STYLE_MUTED) + + tux.console.print() + return None diff --git a/codeai/commands/status.py b/codeai/commands/status.py new file mode 100644 index 0000000..7432cee --- /dev/null +++ b/codeai/commands/status.py @@ -0,0 +1,56 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /status - Show Code AI status.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "status" +ALIASES: list[str] = [] +DESCRIPTION = "Show Code AI status including model, tokens, and connectivity" +SHORTCUT = "escape s" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Show status information.""" + from ..tux import STYLE_PRIMARY, STYLE_MUTED + + tux.console.print() + tux.console.print("● Code AI Status", style=STYLE_PRIMARY) + tux.console.print() + + # Version + from .. import __version__ + tux.console.print(f" Version: {__version__.__version__}", style=STYLE_MUTED) + + # Model + tux.console.print(f" Model: {tux.model_name}", style=STYLE_MUTED) + + # Server + tux.console.print(f" Server: {tux.server_url}", style=STYLE_MUTED) + + # Connection test + try: + async with httpx.AsyncClient() as client: + response = await client.get(f"{tux.server_url}/health", timeout=5.0) + if response.status_code == 200: + tux.console.print(" API: [green]Connected[/green]", style=STYLE_MUTED) + else: + tux.console.print(f" API: [yellow]Status {response.status_code}[/yellow]", style=STYLE_MUTED) + except Exception: + tux.console.print(" API: [red]Disconnected[/red]", style=STYLE_MUTED) + + # Session stats + tux.console.print() + tux.console.print(f" Session tokens: {tux._format_tokens(tux.stats.total_tokens)}", style=STYLE_MUTED) + tux.console.print(f" Messages: {tux.stats.messages}", style=STYLE_MUTED) + tux.console.print() + return None diff --git a/codeai/commands/suggestions.py b/codeai/commands/suggestions.py new file mode 100644 index 0000000..309bfe7 --- /dev/null +++ b/codeai/commands/suggestions.py @@ -0,0 +1,93 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /suggestions - List and pick an agent suggestion.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "suggestions" +ALIASES = ["suggest"] +DESCRIPTION = "List available suggestions and pick one as next prompt" +SHORTCUT = "escape u" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Fetch suggestions from the running agent spec, display them numbered, + and let the user choose one to use as the next prompt. + + Returns: + The chosen suggestion text, or None if cancelled / no suggestions. + """ + from ..tux import STYLE_PRIMARY, STYLE_ACCENT, STYLE_MUTED, STYLE_WARNING + from ..banner import GREEN_MEDIUM, GREEN_LIGHT, GRAY, RESET + + # Fetch the agent spec which contains the suggestions list + suggestions: list[str] = [] + try: + async with httpx.AsyncClient() as client: + url = f"{tux.server_url}/api/v1/configure/agents/{tux.agent_id}/spec" + response = await client.get(url, timeout=10.0) + response.raise_for_status() + data = response.json() + suggestions = data.get("suggestions", []) + except Exception as e: + tux.console.print(f"[red]Error fetching suggestions: {e}[/red]") + return None + + if not suggestions: + tux.console.print() + tux.console.print("● No suggestions available for this agent.", style=STYLE_MUTED) + tux.console.print() + return None + + # Display numbered suggestions + tux.console.print() + tux.console.print(f"● Suggestions ({len(suggestions)}):", style=STYLE_PRIMARY) + tux.console.print() + + for i, suggestion in enumerate(suggestions, 1): + tux.console.print(f" {i}. {suggestion}", style=STYLE_ACCENT) + + tux.console.print() + + # Prompt user to choose + while True: + try: + choice = input( + f"{GREEN_MEDIUM}Choose a suggestion [1-{len(suggestions)}] " + f"(Enter to cancel): {RESET}" + ).strip() + + if not choice: + tux.console.print(" Cancelled.", style=STYLE_MUTED) + return None + + idx = int(choice) - 1 + if 0 <= idx < len(suggestions): + selected = suggestions[idx] + tux.console.print() + tux.console.print(f" {GREEN_LIGHT}Prompting:{RESET} {selected}", style=STYLE_ACCENT) + tux.console.print() + return selected + else: + tux.console.print( + f" Please enter a number between 1 and {len(suggestions)}.", + style=STYLE_MUTED, + ) + except ValueError: + tux.console.print( + f" Please enter a number between 1 and {len(suggestions)}.", + style=STYLE_MUTED, + ) + except (KeyboardInterrupt, EOFError): + tux.console.print() + tux.console.print(" Cancelled.", style=STYLE_MUTED) + return None diff --git a/codeai/commands/tools.py b/codeai/commands/tools.py new file mode 100644 index 0000000..c33a3f9 --- /dev/null +++ b/codeai/commands/tools.py @@ -0,0 +1,57 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /tools - List available tools.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "tools" +ALIASES: list[str] = [] +DESCRIPTION = "List available tools for the current agent" +SHORTCUT = "escape t" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """List available tools for the current agent.""" + from ..tux import STYLE_PRIMARY, STYLE_ACCENT, STYLE_MUTED + + try: + async with httpx.AsyncClient() as client: + url = f"{tux.server_url}/api/v1/configure/agents/{tux.agent_id}/context-snapshot" + response = await client.get(url, timeout=10.0) + response.raise_for_status() + data = response.json() + except Exception as e: + tux.console.print(f"[red]Error fetching tools: {e}[/red]") + return None + + tools = data.get("tools", []) + + if not tools: + tux.console.print("No tools available", style=STYLE_MUTED) + return None + + tux.console.print() + tux.console.print(f"● Available Tools ({len(tools)}):", style=STYLE_PRIMARY) + tux.console.print() + + for tool in tools: + tool_name = tool.get("name", "Unknown") + tool_desc = tool.get("description", "") + # Truncate description if too long + if len(tool_desc) > 60: + tool_desc = tool_desc[:57] + "..." + tux.console.print(f" • {tool_name}", style=STYLE_ACCENT) + if tool_desc: + tux.console.print(f" {tool_desc}", style=STYLE_MUTED) + + tux.console.print() + return None diff --git a/codeai/commands/tools_last.py b/codeai/commands/tools_last.py new file mode 100644 index 0000000..2c20840 --- /dev/null +++ b/codeai/commands/tools_last.py @@ -0,0 +1,78 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /tools-last - Show tool call details from last response.""" + +from __future__ import annotations + +import json +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "tools-last" +ALIASES = ["tl"] +DESCRIPTION = "Show details of tool calls from last response" +SHORTCUT = "escape l" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Show detailed information about tool calls from the last response.""" + from ..tux import ( + STYLE_PRIMARY, STYLE_ACCENT, STYLE_MUTED, + STYLE_ERROR, STYLE_WARNING, + ) + + if not tux.tool_calls: + tux.console.print() + tux.console.print("● No tool calls in the last response", style=STYLE_MUTED) + tux.console.print() + return None + + tux.console.print() + tux.console.print(f"● Tool Calls from Last Response ({len(tux.tool_calls)}):", style=STYLE_PRIMARY) + tux.console.print() + + for i, tc in enumerate(tux.tool_calls, 1): + # Status indicator + if tc.status == "complete": + status_icon = "[green]✓[/green]" + elif tc.status == "error": + status_icon = "[red]✗[/red]" + else: + status_icon = "[yellow]●[/yellow]" + + # Tool header + tux.console.print(f" {status_icon} {i}. {tc.tool_name}", style=STYLE_PRIMARY) + + # Arguments - show complete details + if tc.args_json: + try: + args = json.loads(tc.args_json) + if isinstance(args, dict): + for key, value in args.items(): + val_str = str(value) + # Show full value, preserving newlines with indentation + if "\n" in val_str: + tux.console.print(f" {key}:", style=STYLE_MUTED) + for line in val_str.split("\n"): + tux.console.print(f" {line}", style=STYLE_MUTED) + else: + tux.console.print(f" {key}: {val_str}", style=STYLE_MUTED) + else: + tux.console.print(f" args: {tc.args_json}", style=STYLE_MUTED) + except json.JSONDecodeError: + tux.console.print(f" args: {tc.args_json}", style=STYLE_MUTED) + + # Result - show complete details + if tc.result: + tux.console.print(" result:", style=STYLE_MUTED) + for line in tc.result.split("\n"): + tux.console.print(f" │ {line}", style=STYLE_MUTED) + + tux.console.print() + + tux.console.print() + return None diff --git a/codeai/tux.py b/codeai/tux.py index b7ea76d..00d43e3 100644 --- a/codeai/tux.py +++ b/codeai/tux.py @@ -16,6 +16,8 @@ from pathlib import Path from typing import Callable, Optional, Any +from .commands import SlashCommand, build_commands + import httpx from prompt_toolkit import PromptSession from prompt_toolkit.completion import Completer, Completion @@ -70,16 +72,6 @@ SYMBOL_BUFFER = "⛝" -@dataclass -class SlashCommand: - """Definition of a slash command.""" - name: str - aliases: list[str] = field(default_factory=list) - description: str = "" - handler: Optional[Callable] = None - shortcut: Optional[str] = None # e.g., "c-e" for Ctrl+E - - class SlashCommandCompleter(Completer): """Completer for slash commands with menu-style display.""" @@ -204,8 +196,9 @@ def __init__( self._agui_client: Optional[Any] = None # Persistent AG-UI client for conversation history # Initialize slash commands - self.commands: dict[str, SlashCommand] = {} - self._register_commands() + self.commands: dict[str, SlashCommand] = build_commands( + self, eggs=eggs, jupyter_url=jupyter_url + ) # Initialize prompt session with slash command completer # Style for the completion menu matching Datalayer brand colors @@ -220,150 +213,6 @@ def __init__( }) self.prompt_session: Optional[PromptSession] = None - def _register_commands(self) -> None: - """Register all slash commands. - - Keyboard shortcuts use Escape sequences (press Escape then the key) - to avoid conflicts with terminal control characters like: - - Ctrl+M = Enter, Ctrl+H = Backspace, Ctrl+D = EOF - - Ctrl+A = Start of line, Ctrl+E = End of line, Ctrl+L = Clear - """ - commands = [ - SlashCommand( - name="context", - description="Visualize current context usage as a colored grid", - handler=self._cmd_context, - shortcut="escape x", # Esc, X - ), - SlashCommand( - name="clear", - aliases=["reset", "new"], - description="Clear conversation history and free up context", - handler=self._cmd_clear, - shortcut="escape c", # Esc, C - ), - SlashCommand( - name="help", - aliases=["?"], - description="Show available commands", - handler=self._cmd_help, - shortcut="escape h", # Esc, H - ), - SlashCommand( - name="status", - description="Show Code AI status including model, tokens, and connectivity", - handler=self._cmd_status, - shortcut="escape s", # Esc, S - ), - SlashCommand( - name="exit", - aliases=["quit", "q"], - description="Exit Code AI", - handler=self._cmd_exit, - shortcut="escape q", # Esc, Q - ), - SlashCommand( - name="agents", - description="List available agents on the server", - handler=self._cmd_agents, - shortcut="escape a", # Esc, A - ), - SlashCommand( - name="tools", - description="List available tools for the current agent", - handler=self._cmd_tools, - shortcut="escape t", # Esc, T - ), - SlashCommand( - name="mcp-servers", - aliases=["mcp"], - description="List MCP servers and their status", - handler=self._cmd_mcp_servers, - shortcut="escape m", # Esc, M - ), - SlashCommand( - name="skills", - description="List available skills (requires codemode enabled)", - handler=self._cmd_skills, - shortcut="escape k", # Esc, K (sKills) - ), - SlashCommand( - name="codemode-toggle", - aliases=["codemode"], - description="Toggle codemode on/off for enhanced code capabilities", - handler=self._cmd_codemode_toggle, - shortcut="escape o", # Esc, O (cOdemode) - ), - SlashCommand( - name="context-export", - aliases=["export"], - description="Export the current context to a CSV file", - handler=self._cmd_context_export, - shortcut="escape e", # Esc, E - ), - SlashCommand( - name="tools-last", - aliases=["tl"], - description="Show details of tool calls from last response", - handler=self._cmd_tools_last, - shortcut="escape l", # Esc, L (Last) - ), - SlashCommand( - name="cls", - description="Clear the screen", - handler=self._cmd_cls, - ), - ] - - # Add Easter egg commands if enabled - if self.eggs: - commands.extend([ - SlashCommand( - name="rain", - description="Matrix rain animation", - handler=self._cmd_rain, - shortcut="escape r", # Esc, R - ), - SlashCommand( - name="about", - description="About Datalayer", - handler=self._cmd_about, - shortcut="escape l", # Esc, L - ), - SlashCommand( - name="gif", - description="Black hole spinning animation", - handler=self._cmd_gif, - shortcut="escape g", # Esc, G - ), - ]) - - # Add /jupyter command only when a Jupyter sandbox is active - if self.jupyter_url: - commands.append( - SlashCommand( - name="jupyter", - description="Open the Jupyter server in your browser", - handler=self._cmd_jupyter, - shortcut="escape j", # Esc, J - ), - ) - - # /browser opens the web-based chat UI served by the agent-runtimes server - commands.append( - SlashCommand( - name="browser", - description="Open the Agent chat UI in your browser", - handler=self._cmd_browser, - shortcut="escape w", # Esc, W (web) - ), - ) - - for cmd in commands: - self.commands[cmd.name] = cmd - for alias in cmd.aliases: - self.commands[alias] = cmd - def _format_tokens(self, tokens: int) -> str: """Format token count with K suffix for thousands.""" if tokens >= 1000: From 6a601db5d086125f1cfed74be7f35f0182fac714 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Tue, 17 Feb 2026 17:19:41 +0100 Subject: [PATCH 2/5] show time --- .gitignore | 1 + Makefile | 17 +- codeai/__init__.py | 2 +- codeai/cli.py | 9 +- codeai/commands/suggestions.py | 9 +- codeai/tux.py | 585 ++--------------------------- docs/docs/implementation/index.mdx | 4 +- docs/docs/index.mdx | 2 +- 8 files changed, 69 insertions(+), 560 deletions(-) diff --git a/.gitignore b/.gitignore index adde7bf..75a22a2 100644 --- a/.gitignore +++ b/.gitignore @@ -125,4 +125,5 @@ docs/.yarn/* uv.lock +tmp/ *.csv \ No newline at end of file diff --git a/Makefile b/Makefile index 2481f48..110ca93 100644 --- a/Makefile +++ b/Makefile @@ -75,20 +75,16 @@ codeai-financial: # codeai-financial ALPHA_VANTAGE_API_KEY must be set in env AWS_DEFAULT_REGION=${DATALAYER_BEDROCK_AWS_DEFAULT_REGION} \ codeai --eggs --agentspec-id datalayer-ai/financial -########## -# Demo # -########## - -# List files located in the sales-data folder of my Google Drive account (eric@datalayer.io). -# Aggregate all CSV files located in the sales-data folder of my Google Drive account (eric@datalayer.io) into a single file named sales_21-25.csv, and save this aggregated file in the sales-data directory of the echarles/openteams-codemode-demo repository. - codeai-demo: # codeai-demo @AWS_ACCESS_KEY_ID=${DATALAYER_BEDROCK_AWS_ACCESS_KEY_ID} \ AWS_SECRET_ACCESS_KEY=${DATALAYER_BEDROCK_AWS_SECRET_ACCESS_KEY} \ AWS_DEFAULT_REGION=${DATALAYER_BEDROCK_AWS_DEFAULT_REGION} \ GOOGLE_OAUTH_CLIENT_ID=${OPENTEAMS_DEMO_GOOGLE_CLIENT_ID} \ GOOGLE_OAUTH_CLIENT_SECRET=${OPENTEAMS_DEMO_GOOGLE_CLIENT_SECRET} \ - codeai --eggs --agentspec-id codemode-paper/information-routing + codeai \ + --eggs \ + --suggestions "List files located in the sales-data folder of my Google Drive account (eric@datalayer.io),Aggregate all CSV files located in the sales-data folder of my Google Drive account (eric@datalayer.io) into a single file named sales_21-25.csv and save this aggregated file in the sales-data directory of the echarles/openteams-codemode-demo repository." \ + --agentspec-id codemode-paper/information-routing codeai-demo-nocodemode: # codeai-demo-nocodemode @AWS_ACCESS_KEY_ID=${DATALAYER_BEDROCK_AWS_ACCESS_KEY_ID} \ @@ -96,4 +92,7 @@ codeai-demo-nocodemode: # codeai-demo-nocodemode AWS_DEFAULT_REGION=${DATALAYER_BEDROCK_AWS_DEFAULT_REGION} \ GOOGLE_OAUTH_CLIENT_ID=${OPENTEAMS_DEMO_GOOGLE_CLIENT_ID} \ GOOGLE_OAUTH_CLIENT_SECRET=${OPENTEAMS_DEMO_GOOGLE_CLIENT_SECRET} \ - codeai --eggs --agentspec-id codemode-paper/information-routing --no-codemode + codeai --eggs \ + --agentspec-id codemode-paper/information-routing \ + --suggestions "List files located in the sales-data folder of my Google Drive account (eric@datalayer.io),Aggregate all CSV files located in the sales-data folder of my Google Drive account (eric@datalayer.io) into a single file named sales_21-25.csv and save this aggregated file in the sales-data directory of the echarles/openteams-codemode-demo repository." \ + --no-codemode diff --git a/codeai/__init__.py b/codeai/__init__.py index 32f0961..accdf07 100644 --- a/codeai/__init__.py +++ b/codeai/__init__.py @@ -9,7 +9,7 @@ - CLI interface for AI-powered code assistance - ACP (Agent Communication Protocol) client for remote agent connections - Interactive chat mode with local and remote agents -- Terminal UX (TUX) with Claude Code-style interface +- Terminal UX (TUX) """ from codeai.cli import agent, main diff --git a/codeai/cli.py b/codeai/cli.py index a58e011..07eade3 100644 --- a/codeai/cli.py +++ b/codeai/cli.py @@ -587,6 +587,12 @@ def main_callback( "--no-codemode", help="Disable codemode (MCP tools as programmatic tools)" ), + suggestions: Optional[str] = typer.Option( + None, + "--suggestions", + "-s", + help="Extra suggestions to add (comma-separated), e.g. 'Search for X,Summarize Y'" + ), eggs: bool = typer.Option( False, "--eggs", @@ -739,7 +745,8 @@ def main_callback( try: # Use Rich-based TUX from .tux import run_tux - asyncio.run(run_tux(url, server_url, agent_id="codeai", eggs=eggs, jupyter_url=jupyter_url)) + extra_suggestions = [s.strip() for s in suggestions.split(",") if s.strip()] if suggestions else [] + asyncio.run(run_tux(url, server_url, agent_id="codeai", eggs=eggs, jupyter_url=jupyter_url, extra_suggestions=extra_suggestions)) finally: _cleanup_subprocess() else: diff --git a/codeai/commands/suggestions.py b/codeai/commands/suggestions.py index 309bfe7..772aea6 100644 --- a/codeai/commands/suggestions.py +++ b/codeai/commands/suggestions.py @@ -27,7 +27,7 @@ async def execute(tux: "CodeAITux") -> Optional[str]: The chosen suggestion text, or None if cancelled / no suggestions. """ from ..tux import STYLE_PRIMARY, STYLE_ACCENT, STYLE_MUTED, STYLE_WARNING - from ..banner import GREEN_MEDIUM, GREEN_LIGHT, GRAY, RESET + from ..banner import GREEN_MEDIUM, RESET # Fetch the agent spec which contains the suggestions list suggestions: list[str] = [] @@ -42,6 +42,10 @@ async def execute(tux: "CodeAITux") -> Optional[str]: tux.console.print(f"[red]Error fetching suggestions: {e}[/red]") return None + # Append extra suggestions provided via --suggestions CLI flag + if tux.extra_suggestions: + suggestions = suggestions + tux.extra_suggestions + if not suggestions: tux.console.print() tux.console.print("● No suggestions available for this agent.", style=STYLE_MUTED) @@ -74,7 +78,8 @@ async def execute(tux: "CodeAITux") -> Optional[str]: if 0 <= idx < len(suggestions): selected = suggestions[idx] tux.console.print() - tux.console.print(f" {GREEN_LIGHT}Prompting:{RESET} {selected}", style=STYLE_ACCENT) + tux.console.print(" Selected:", style=STYLE_PRIMARY, end=" ") + tux.console.print(selected, style=STYLE_ACCENT) tux.console.print() return selected else: diff --git a/codeai/tux.py b/codeai/tux.py index 00d43e3..a166d5c 100644 --- a/codeai/tux.py +++ b/codeai/tux.py @@ -2,19 +2,16 @@ # # BSD 3-Clause License -"""Terminal UX (TUX) for Code AI - Claude Code inspired interface.""" +"""Terminal UX (TUX) for Code AI.""" import asyncio import getpass import json -import os -import random -import shutil import sys import time from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, Optional, Any +from typing import Optional, Any from .commands import SlashCommand, build_commands @@ -27,25 +24,15 @@ from prompt_toolkit.styles import Style as PTStyle from rich.console import Console from rich.panel import Panel -from rich.table import Table from rich.text import Text from rich.columns import Columns -from rich.markdown import Markdown from rich.live import Live -from rich.spinner import Spinner as RichSpinner from rich.style import Style -from rich.box import ROUNDED, HEAVY +from rich.box import ROUNDED from .banner import ( - GREEN_DARK, - GREEN_MEDIUM, - GREEN_LIGHT, - GRAY, - WHITE, - RESET, GOODBYE_MESSAGE, ) -from .animations import rain_animation, about_animation, gif_animation # Rich styles matching Datalayer brand # Brand color reference (from BRAND_MANUAL.md): @@ -163,7 +150,7 @@ def total_tokens(self) -> int: class CodeAITux: - """Terminal UX for Code AI with Claude Code inspired interface.""" + """Terminal UX for Code AI.""" def __init__( self, @@ -172,6 +159,7 @@ def __init__( agent_id: str = "codeai", eggs: bool = False, jupyter_url: Optional[str] = None, + extra_suggestions: Optional[list[str]] = None, ): """Initialize the TUX. @@ -181,12 +169,14 @@ def __init__( agent_id: Agent ID for API calls eggs: Enable Easter egg commands jupyter_url: Jupyter server URL (only set when sandbox is jupyter) + extra_suggestions: Additional suggestions provided via --suggestions flag """ self.agent_url = agent_url self.server_url = server_url.rstrip("/") self.agent_id = agent_id self.eggs = eggs self.jupyter_url = jupyter_url + self.extra_suggestions: list[str] = extra_suggestions or [] self.console = Console() self.stats = SessionStats() self.running = False @@ -233,7 +223,7 @@ def _get_cwd(self) -> str: return str(cwd) def show_welcome(self) -> None: - """Display the welcome banner similar to Claude Code.""" + """Display the welcome banner.""" username = self._get_username() cwd = self._get_cwd() @@ -358,460 +348,17 @@ async def show_prompt(self) -> str: except KeyboardInterrupt: return "" - async def _cmd_context(self) -> None: - """Display context usage visualization.""" - try: - async with httpx.AsyncClient() as client: - url = f"{self.server_url}/api/v1/configure/agents/{self.agent_id}/context-table?show_context=false" - response = await client.get(url, timeout=10.0) - response.raise_for_status() - data = response.json() - except Exception as e: - self.console.print(f"[red]Error fetching context: {e}[/red]") - return - - if data.get("error"): - self.console.print(f"[red]{data.get('error')}[/red]") - return - - table_text = data.get("table", "").rstrip() - if table_text: - self.console.print(Text.from_ansi(table_text)) - else: - self.console.print("[red]No table content returned.[/red]") - return - - async def _cmd_cls(self) -> None: - """Clear the screen.""" - self.console.clear() - - async def _cmd_jupyter(self) -> None: - """Open the Jupyter server API page in the default browser.""" - import webbrowser - if self.jupyter_url: - # Append /api so the browser lands on the Jupyter REST API root - sep = "&" if "?" in self.jupyter_url else "?" - base = self.jupyter_url.split("?")[0].rstrip("/") + "/api" - query = self.jupyter_url.split("?")[1] if "?" in self.jupyter_url else None - url = f"{base}?{query}" if query else base - self.console.print(f" Opening [bold cyan]{url}[/bold cyan]") - webbrowser.open(url) - else: - self.console.print(" [yellow]No Jupyter server available.[/yellow]") - - async def _cmd_browser(self) -> None: - """Open the Agent chat web UI in the default browser.""" - import webbrowser - url = f"{self.server_url}/static/agent.html?agentId={self.agent_id}" - self.console.print(f" Opening [bold cyan]{url}[/bold cyan]") - webbrowser.open(url) - - async def _cmd_clear(self) -> None: - """Clear conversation history.""" - try: - async with httpx.AsyncClient() as client: - url = f"{self.server_url}/api/v1/configure/agents/{self.agent_id}/context-details/reset" - response = await client.post(url, timeout=10.0) - response.raise_for_status() - except Exception as e: - self.console.print(f"[red]Error clearing context: {e}[/red]") - return - - # Reset local AG-UI client to clear conversation history - if self._agui_client is not None: - await self._agui_client.disconnect() - self._agui_client = None - - self.stats = SessionStats() - self.console.print("● Conversation cleared. Starting fresh.", style=STYLE_PRIMARY) - - async def _cmd_help(self) -> None: - """Show available commands.""" - self.console.print() - self.console.print("Available Commands:", style=STYLE_WHITE) - self.console.print() - - # Format shortcuts nicely - def format_shortcut(shortcut: Optional[str]) -> str: - if not shortcut: - return "" - # Convert "escape x" to "Esc,X" - if shortcut.startswith("escape "): - return f"Esc,{shortcut[7:].upper()}" - # Convert "c-x" to "Ctrl+X" - if shortcut.startswith("c-"): - return f"Ctrl+{shortcut[2:].upper()}" - return shortcut - - shown = set() - for name, cmd in sorted(self.commands.items()): - if cmd.name in shown: - continue - shown.add(cmd.name) - - # Build command name with aliases - aliases_str = "" - if cmd.aliases: - aliases_str = f" ({', '.join(cmd.aliases)})" - - # Build shortcut indicator - shortcut_str = "" - if cmd.shortcut: - shortcut_str = f" [{format_shortcut(cmd.shortcut)}]" - - cmd_display = f"/{cmd.name}{aliases_str}" - self.console.print(f" {cmd_display}", style=STYLE_PRIMARY, end="") - - # Calculate padding for alignment - padding_len = max(1, 22 - len(cmd_display)) - self.console.print(" " * padding_len, end="") - self.console.print(cmd.description, style=STYLE_MUTED, end="") - - if shortcut_str: - self.console.print(f" {shortcut_str}", style=STYLE_SECONDARY) - else: - self.console.print() - - self.console.print() - - async def _cmd_status(self) -> None: - """Show status information.""" - self.console.print() - self.console.print("● Code AI Status", style=STYLE_PRIMARY) - self.console.print() - - # Version - from . import __version__ - self.console.print(f" Version: {__version__.__version__}", style=STYLE_MUTED) - - # Model - self.console.print(f" Model: {self.model_name}", style=STYLE_MUTED) - - # Server - self.console.print(f" Server: {self.server_url}", style=STYLE_MUTED) - - # Connection test - try: - async with httpx.AsyncClient() as client: - response = await client.get(f"{self.server_url}/health", timeout=5.0) - if response.status_code == 200: - self.console.print(" API: [green]Connected[/green]", style=STYLE_MUTED) - else: - self.console.print(f" API: [yellow]Status {response.status_code}[/yellow]", style=STYLE_MUTED) - except Exception: - self.console.print(" API: [red]Disconnected[/red]", style=STYLE_MUTED) - - # Session stats - self.console.print() - self.console.print(f" Session tokens: {self._format_tokens(self.stats.total_tokens)}", style=STYLE_MUTED) - self.console.print(f" Messages: {self.stats.messages}", style=STYLE_MUTED) - self.console.print() - - async def _cmd_exit(self) -> None: - """Exit the application.""" - self.running = False - - # Clean up AG-UI client - if self._agui_client is not None: - await self._agui_client.disconnect() - self._agui_client = None - - self.console.print() - self.console.print(GOODBYE_MESSAGE, style=STYLE_ACCENT) - self.console.print(" [link=https://datalayer.ai]https://datalayer.ai[/link]", style=STYLE_MUTED) - self.console.print() - - async def _cmd_agents(self) -> None: - """List available agents with detailed information.""" - try: - async with httpx.AsyncClient() as client: - url = f"{self.server_url}/api/v1/agents" - response = await client.get(url, timeout=10.0) - response.raise_for_status() - data = response.json() - except Exception as e: - self.console.print(f"[red]Error fetching agents: {e}[/red]") - return - - agents_list = data.get("agents", []) - - if not agents_list: - self.console.print("No agents available", style=STYLE_MUTED) - return - - self.console.print() - self.console.print(f"● Available Agents ({len(agents_list)}):", style=STYLE_PRIMARY) - self.console.print() - - for agent in agents_list: - agent_id = agent.get("id", "unknown") - name = agent.get("name", "Unknown") - description = agent.get("description", "") - model = agent.get("model", "unknown") - status = agent.get("status", "unknown") - toolsets = agent.get("toolsets", {}) - - # Status indicator - status_icon = "[green]●[/green]" if status == "running" else "[red]○[/red]" - self.console.print(f" {status_icon} {name} ({agent_id})", style=STYLE_ACCENT) - - # Description - if description: - desc = description[:60] + "..." if len(description) > 60 else description - self.console.print(f" {desc}", style=STYLE_MUTED) - - # Model - self.console.print(f" Model: {model}", style=STYLE_MUTED) - - # Codemode - codemode = toolsets.get("codemode", False) - codemode_text = "enabled" if codemode else "disabled" - codemode_style = STYLE_ACCENT if codemode else STYLE_MUTED - self.console.print(f" Codemode: ", style=STYLE_MUTED, end="") - self.console.print(codemode_text, style=codemode_style) - - # MCP Servers - mcp_servers = toolsets.get("mcp_servers", []) - if mcp_servers: - mcp_text = ", ".join(mcp_servers[:5]) - if len(mcp_servers) > 5: - mcp_text += f" (+{len(mcp_servers) - 5} more)" - self.console.print(f" MCP Servers: {mcp_text}", style=STYLE_MUTED) - - # Tools count - tools_count = toolsets.get("tools_count", 0) - if tools_count > 0: - self.console.print(f" Tools: {tools_count}", style=STYLE_MUTED) - - # Skills - skills = toolsets.get("skills", []) - if skills: - skill_names = [] - for s in skills[:3]: - if isinstance(s, dict): - skill_names.append(s.get("name", "?")) - else: - skill_names.append(str(s)) - skills_text = ", ".join(skill_names) - if len(skills) > 3: - skills_text += f" (+{len(skills) - 3} more)" - self.console.print(f" Skills: {skills_text}", style=STYLE_MUTED) - - self.console.print() - - async def _cmd_tools(self) -> None: - """List available tools for the current agent.""" - try: - async with httpx.AsyncClient() as client: - url = f"{self.server_url}/api/v1/configure/agents/{self.agent_id}/context-snapshot" - response = await client.get(url, timeout=10.0) - response.raise_for_status() - data = response.json() - except Exception as e: - self.console.print(f"[red]Error fetching tools: {e}[/red]") - return - - tools = data.get("tools", []) - - if not tools: - self.console.print("No tools available", style=STYLE_MUTED) - return - - self.console.print() - self.console.print(f"● Available Tools ({len(tools)}):", style=STYLE_PRIMARY) - self.console.print() - - for tool in tools: - tool_name = tool.get("name", "Unknown") - tool_desc = tool.get("description", "") - # Truncate description if too long - if len(tool_desc) > 60: - tool_desc = tool_desc[:57] + "..." - self.console.print(f" • {tool_name}", style=STYLE_ACCENT) - if tool_desc: - self.console.print(f" {tool_desc}", style=STYLE_MUTED) - - self.console.print() - - async def _cmd_mcp_servers(self) -> None: - """List MCP servers and their status.""" - try: - async with httpx.AsyncClient() as client: - url = f"{self.server_url}/api/v1/mcp/servers" - response = await client.get(url, timeout=10.0) - response.raise_for_status() - servers = response.json() - except Exception as e: - self.console.print(f"[red]Error fetching MCP servers: {e}[/red]") - return - - if not servers: - self.console.print("No MCP servers running", style=STYLE_MUTED) - return - - self.console.print() - self.console.print(f"● MCP Servers ({len(servers)}):", style=STYLE_PRIMARY) - self.console.print() - - for server in servers: - server_id = server.get("id", "Unknown") - server_name = server.get("name", server_id) - is_available = server.get("isAvailable", False) - tools = server.get("tools", []) - - status = "[green]●[/green]" if is_available else "[red]●[/red]" - self.console.print(f" {status} {server_name}", style=STYLE_ACCENT) - - if tools: - tool_names = [t.get("name", "?") for t in tools[:5]] - tools_str = ", ".join(tool_names) - if len(tools) > 5: - tools_str += f" (+{len(tools) - 5} more)" - self.console.print(f" Tools: {tools_str}", style=STYLE_MUTED) - - self.console.print() - - async def _cmd_skills(self) -> None: - """List available skills (requires codemode enabled).""" - # First check if codemode is enabled - try: - async with httpx.AsyncClient() as client: - url = f"{self.server_url}/api/v1/configure/codemode-status" - response = await client.get(url, timeout=10.0) - response.raise_for_status() - status_data = response.json() - except Exception as e: - self.console.print(f"[red]Error checking codemode status: {e}[/red]") - return - - codemode_enabled = status_data.get("enabled", False) - - if not codemode_enabled: - self.console.print() - self.console.print("● Codemode is disabled", style=STYLE_WARNING) - self.console.print(" Skills are only available when codemode is enabled.", style=STYLE_MUTED) - self.console.print(" Use /codemode-toggle to enable it.", style=STYLE_MUTED) - self.console.print() - return - - # Get skills from codemode status (it includes available_skills) - skills = status_data.get("available_skills", []) - active_skills = {s.get("name") for s in status_data.get("skills", [])} - - if not skills: - self.console.print("No skills available", style=STYLE_MUTED) - return - - self.console.print() - self.console.print(f"● Available Skills ({len(skills)}):", style=STYLE_PRIMARY) - self.console.print() - - for skill in skills: - skill_name = skill.get("name", "Unknown") - skill_desc = skill.get("description", "") - is_active = skill_name in active_skills - # Truncate description if too long - if len(skill_desc) > 60: - skill_desc = skill_desc[:57] + "..." - # Show active status - status_icon = "[green]●[/green]" if is_active else "○" - self.console.print(f" {status_icon} {skill_name}", style=STYLE_ACCENT if is_active else STYLE_MUTED) - if skill_desc: - self.console.print(f" {skill_desc}", style=STYLE_MUTED) - - self.console.print() - - async def _cmd_codemode_toggle(self) -> None: - """Toggle codemode on/off.""" - # First get current status - try: - async with httpx.AsyncClient() as client: - status_url = f"{self.server_url}/api/v1/configure/codemode-status" - status_response = await client.get(status_url, timeout=10.0) - status_response.raise_for_status() - current_status = status_response.json() - except Exception as e: - self.console.print(f"[red]Error checking codemode status: {e}[/red]") - return - - current_enabled = current_status.get("enabled", False) - new_enabled = not current_enabled - - # Toggle to opposite state - try: - async with httpx.AsyncClient() as client: - url = f"{self.server_url}/api/v1/configure/codemode/toggle" - response = await client.post( - url, - json={"enabled": new_enabled}, - timeout=10.0 - ) - response.raise_for_status() - data = response.json() - except Exception as e: - self.console.print(f"[red]Error toggling codemode: {e}[/red]") - return - - enabled = data.get("enabled", False) - - self.console.print() - if enabled: - self.console.print("● Codemode enabled", style=STYLE_ACCENT) - self.console.print(" Enhanced code capabilities are now active.", style=STYLE_MUTED) - self.console.print(" Use /skills to see available skills.", style=STYLE_MUTED) - else: - self.console.print("● Codemode disabled", style=STYLE_WARNING) - self.console.print(" Standard mode is now active.", style=STYLE_MUTED) - self.console.print() - - async def _cmd_context_export(self) -> None: - """Export the current context to a CSV file.""" - try: - async with httpx.AsyncClient() as client: - url = f"{self.server_url}/api/v1/configure/agents/{self.agent_id}/context-export" - response = await client.get(url, timeout=10.0) - response.raise_for_status() - data = response.json() - except Exception as e: - self.console.print(f"[red]Error fetching context: {e}[/red]") - return - - if data.get("error"): - self.console.print(f"[red]{data.get('error')}[/red]") - return - - filename = data.get("filename", "codeai_context.csv") - csv_content = data.get("csv", "") - - if not csv_content: - self.console.print("[red]No CSV content returned.[/red]") - return - - try: - with open(filename, "w", newline="") as csvfile: - csvfile.write(csv_content) - - tools_count = data.get("toolsCount", 0) - messages_count = data.get("messagesCount", 0) - - self.console.print() - self.console.print(f"● Context exported to {filename}", style=STYLE_ACCENT) - if tools_count or messages_count: - self.console.print( - f" Contains {tools_count} tools and {messages_count} messages", - style=STYLE_MUTED, - ) - self.console.print() - except IOError as e: - self.console.print(f"[red]Error writing file: {e}[/red]") - - async def handle_command(self, user_input: str) -> bool: + async def handle_command(self, user_input: str) -> Optional[str]: """Handle a slash command. - Returns True if a command was handled, False otherwise. + Returns: + None if no command matched or the command produced no follow-up. + A non-empty string when a command returns a prompt to send to the agent + (e.g. /suggestions returns the chosen suggestion text). + The empty string "" signals the command was handled but has no follow-up. """ if not user_input.startswith("/"): - return False + return None parts = user_input[1:].split(maxsplit=1) cmd_name = parts[0].lower() if parts else "" @@ -820,13 +367,16 @@ async def handle_command(self, user_input: str) -> bool: if cmd_name in self.commands: cmd = self.commands[cmd_name] if cmd.handler: - await cmd.handler() - return True + result = await cmd.handler() + # Commands may return a string to use as the next prompt + if result: + return result + return "" # Command handled, no follow-up else: # Unknown command - show error with hint self.console.print(f"Unknown command: /{cmd_name}", style=STYLE_ERROR) self.console.print("Type /help to see available commands, or start typing / to see suggestions.", style=STYLE_MUTED) - return True + return "" # Handled (error shown) async def send_message(self, message: str) -> None: """Send a message to the agent and stream the response.""" @@ -836,6 +386,7 @@ async def send_message(self, message: str) -> None: self.stats.messages += 1 self.tool_calls = [] # Reset tool calls for this response current_tool_call: Optional[ToolCallInfo] = None + turn_start = time.monotonic() try: # Create or reuse the AG-UI client for conversation history @@ -950,9 +501,16 @@ async def send_message(self, message: str) -> None: self.console.print(usage_line) total = input_tokens + output_tokens + elapsed = time.monotonic() - turn_start + if elapsed < 60: + time_str = f"{elapsed:.1f}s" + else: + minutes, secs = divmod(elapsed, 60) + time_str = f"{int(minutes)}m {secs:.0f}s" self.console.print( f" {self._format_tokens(total)} tokens used · " - f"{self._format_tokens(input_tokens)} in / {self._format_tokens(output_tokens)} out", + f"{self._format_tokens(input_tokens)} in / {self._format_tokens(output_tokens)} out · " + f"{time_str}", style=STYLE_MUTED, ) self.console.print() @@ -981,74 +539,6 @@ def _show_tool_calls_summary(self) -> None: ) self.console.print("\\[/tools-last for details]", style=Style(color="rgb(89,89,92)", italic=True)) - async def _cmd_tools_last(self) -> None: - """Show detailed information about tool calls from the last response.""" - if not self.tool_calls: - self.console.print() - self.console.print("● No tool calls in the last response", style=STYLE_MUTED) - self.console.print() - return - - self.console.print() - self.console.print(f"● Tool Calls from Last Response ({len(self.tool_calls)}):", style=STYLE_PRIMARY) - self.console.print() - - for i, tc in enumerate(self.tool_calls, 1): - # Status indicator - if tc.status == "complete": - status_icon = "[green]✓[/green]" - status_style = STYLE_ACCENT - elif tc.status == "error": - status_icon = "[red]✗[/red]" - status_style = STYLE_ERROR - else: - status_icon = "[yellow]●[/yellow]" - status_style = STYLE_WARNING - - # Tool header - self.console.print(f" {status_icon} {i}. {tc.tool_name}", style=STYLE_PRIMARY) - - # Arguments - show complete details - if tc.args_json: - try: - args = json.loads(tc.args_json) - if isinstance(args, dict): - for key, value in args.items(): - val_str = str(value) - # Show full value, preserving newlines with indentation - if "\n" in val_str: - self.console.print(f" {key}:", style=STYLE_MUTED) - for line in val_str.split("\n"): - self.console.print(f" {line}", style=STYLE_MUTED) - else: - self.console.print(f" {key}: {val_str}", style=STYLE_MUTED) - else: - self.console.print(f" args: {tc.args_json}", style=STYLE_MUTED) - except json.JSONDecodeError: - self.console.print(f" args: {tc.args_json}", style=STYLE_MUTED) - - # Result - show complete details - if tc.result: - self.console.print(" result:", style=STYLE_MUTED) - for line in tc.result.split("\n"): - self.console.print(f" │ {line}", style=STYLE_MUTED) - - self.console.print() - - self.console.print() - - async def _cmd_rain(self) -> None: - """Display Matrix rain animation (5 seconds).""" - await rain_animation(self.console) - - async def _cmd_about(self) -> None: - """Display About Datalayer animation.""" - await about_animation(self.console) - - async def _cmd_gif(self) -> None: - """Display black hole spinning animation (5 seconds).""" - await gif_animation(self.console) - async def run(self) -> None: """Run the main TUX loop.""" self.running = True @@ -1077,15 +567,20 @@ async def run(self) -> None: # Check for slash commands if user_input.startswith("/"): - await self.handle_command(user_input) + result = await self.handle_command(user_input) + # If a command returned a prompt string, send it to the agent + if result: + await self.send_message(result) else: await self.send_message(user_input) except KeyboardInterrupt: self.console.print() - await self._cmd_exit() + from .commands import exit as _exit_cmd + await _exit_cmd.execute(self) except EOFError: - await self._cmd_exit() + from .commands import exit as _exit_cmd + await _exit_cmd.execute(self) async def run_tux( @@ -1094,6 +589,7 @@ async def run_tux( agent_id: str = "codeai", eggs: bool = False, jupyter_url: Optional[str] = None, + extra_suggestions: Optional[list[str]] = None, ) -> None: """Run the Code AI TUX. @@ -1103,6 +599,7 @@ async def run_tux( agent_id: Agent ID for API calls eggs: Enable Easter egg commands jupyter_url: Jupyter server URL (only set when sandbox is jupyter) + extra_suggestions: Additional suggestions provided via --suggestions flag """ - tux = CodeAITux(agent_url, server_url, agent_id, eggs=eggs, jupyter_url=jupyter_url) + tux = CodeAITux(agent_url, server_url, agent_id, eggs=eggs, jupyter_url=jupyter_url, extra_suggestions=extra_suggestions) await tux.run() diff --git a/docs/docs/implementation/index.mdx b/docs/docs/implementation/index.mdx index 2241d76..db26344 100644 --- a/docs/docs/implementation/index.mdx +++ b/docs/docs/implementation/index.mdx @@ -63,12 +63,12 @@ codeai codeai --agentspec-id data-acquisition # Single query -codeai "How do I create a pandas DataFrame?" +codeai "How do I create a pandas dataframe?" ``` ### Terminal UX (`tux.py`) -The `CodeAITux` class provides a Claude Code-inspired terminal interface built with [Rich](https://github.com/Textualize/rich) and [prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/). +The `CodeAITux` class provides a terminal interface built with [Rich](https://github.com/Textualize/rich) and [prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/). **Features:** - Side-by-side welcome panel with logo, tips, and version info diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index 2c5537c..50becad 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -24,7 +24,7 @@ This starts an interactive session with a background codeai server and the defau ## Features -- **Interactive TUX** — Claude Code-inspired terminal interface with Rich rendering, slash commands, and keyboard shortcuts +- **Interactive TUX** — Terminal interface with Rich rendering, slash commands, and keyboard shortcuts - **Agent Specs** — launch preconfigured agents from the [agentspecs catalogue](https://github.com/datalayer/agentspecs) with `--agentspec-id` or pick one interactively - **MCP Tools** — agents can use MCP servers (search, file access, APIs) as tools - **Codemode** — MCP tools are exposed as programmatic tools the LLM can compose into code From 651b22502229ce4576efa545f9f521cba131048f Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Tue, 17 Feb 2026 19:25:55 +0100 Subject: [PATCH 3/5] browser notebook command --- Makefile | 3 ++- codeai/commands/__init__.py | 2 ++ codeai/commands/browser_notebook.py | 38 +++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 codeai/commands/browser_notebook.py diff --git a/Makefile b/Makefile index 110ca93..774edda 100644 --- a/Makefile +++ b/Makefile @@ -92,7 +92,8 @@ codeai-demo-nocodemode: # codeai-demo-nocodemode AWS_DEFAULT_REGION=${DATALAYER_BEDROCK_AWS_DEFAULT_REGION} \ GOOGLE_OAUTH_CLIENT_ID=${OPENTEAMS_DEMO_GOOGLE_CLIENT_ID} \ GOOGLE_OAUTH_CLIENT_SECRET=${OPENTEAMS_DEMO_GOOGLE_CLIENT_SECRET} \ - codeai --eggs \ + codeai \ + --eggs \ --agentspec-id codemode-paper/information-routing \ --suggestions "List files located in the sales-data folder of my Google Drive account (eric@datalayer.io),Aggregate all CSV files located in the sales-data folder of my Google Drive account (eric@datalayer.io) into a single file named sales_21-25.csv and save this aggregated file in the sales-data directory of the echarles/openteams-codemode-demo repository." \ --no-codemode diff --git a/codeai/commands/__init__.py b/codeai/commands/__init__.py index dfe0fd7..c64558f 100644 --- a/codeai/commands/__init__.py +++ b/codeai/commands/__init__.py @@ -61,6 +61,7 @@ def build_commands( tools_last, cls, browser, + browser_notebook, suggestions, ) @@ -80,6 +81,7 @@ def build_commands( tools_last, cls, browser, + browser_notebook, suggestions, ] diff --git a/codeai/commands/browser_notebook.py b/codeai/commands/browser_notebook.py new file mode 100644 index 0000000..c7da6f8 --- /dev/null +++ b/codeai/commands/browser_notebook.py @@ -0,0 +1,38 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /browser-notebook - Open the Agent Notebook UI in the browser.""" + +from __future__ import annotations + +import webbrowser +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "browser-notebook" +ALIASES: list[str] = [] +DESCRIPTION = "Open the Agent Notebook UI in your browser" +SHORTCUT = "escape n" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Open the Agent Notebook web UI (notebook + chat) in the default browser.""" + url = f"{tux.server_url}/static/agent-notebook.html?agentId={tux.agent_id}" + if tux.jupyter_url: + # Forward Jupyter connection info so the page can reach the kernel + import urllib.parse + base = tux.jupyter_url.split("?")[0].rstrip("/") + query = tux.jupyter_url.split("?")[1] if "?" in tux.jupyter_url else None + token = "" + if query: + params = urllib.parse.parse_qs(query) + token = params.get("token", [""])[0] + url += f"&jupyterBaseUrl={urllib.parse.quote(base, safe='')}" + if token: + url += f"&jupyterToken={urllib.parse.quote(token, safe='')}" + tux.console.print(f" Opening [bold cyan]{url}[/bold cyan]") + webbrowser.open(url) + return None From 1416919d4005c3eefacf8a44da3dca1c979098bd Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Wed, 18 Feb 2026 10:02:39 +0100 Subject: [PATCH 4/5] makefile --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index 774edda..0d5248d 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,12 @@ codeai: # codeai AWS_DEFAULT_REGION=${DATALAYER_BEDROCK_AWS_DEFAULT_REGION} \ codeai --eggs +codeai-simple: # codeai-simple + @AWS_ACCESS_KEY_ID=${DATALAYER_BEDROCK_AWS_ACCESS_KEY_ID} \ + AWS_SECRET_ACCESS_KEY=${DATALAYER_BEDROCK_AWS_SECRET_ACCESS_KEY} \ + AWS_DEFAULT_REGION=${DATALAYER_BEDROCK_AWS_DEFAULT_REGION} \ + codeai --eggs --agentspec-id codeai/simple + codeai-data-acquisition: # codeai-data-acquisition KAGGLE_TOKEN and TAVILY_API_KEY must be set in env @AWS_ACCESS_KEY_ID=${DATALAYER_BEDROCK_AWS_ACCESS_KEY_ID} \ AWS_SECRET_ACCESS_KEY=${DATALAYER_BEDROCK_AWS_SECRET_ACCESS_KEY} \ From d582aa78a663a244083d159624217273d9b6e3d4 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Wed, 18 Feb 2026 13:20:12 +0100 Subject: [PATCH 5/5] feat: browser lexical --- codeai/commands/__init__.py | 2 ++ codeai/commands/browser_lexical.py | 38 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 codeai/commands/browser_lexical.py diff --git a/codeai/commands/__init__.py b/codeai/commands/__init__.py index c64558f..6f1815b 100644 --- a/codeai/commands/__init__.py +++ b/codeai/commands/__init__.py @@ -62,6 +62,7 @@ def build_commands( cls, browser, browser_notebook, + browser_lexical, suggestions, ) @@ -82,6 +83,7 @@ def build_commands( cls, browser, browser_notebook, + browser_lexical, suggestions, ] diff --git a/codeai/commands/browser_lexical.py b/codeai/commands/browser_lexical.py new file mode 100644 index 0000000..c0f6f03 --- /dev/null +++ b/codeai/commands/browser_lexical.py @@ -0,0 +1,38 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Slash command: /browser-lexical - Open the Agent Lexical UI in the browser.""" + +from __future__ import annotations + +import webbrowser +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..tux import CodeAITux + +NAME = "browser-lexical" +ALIASES: list[str] = [] +DESCRIPTION = "Open the Agent Lexical UI in your browser" +SHORTCUT = "escape l" + + +async def execute(tux: "CodeAITux") -> Optional[str]: + """Open the Agent Lexical web UI (lexical editor + chat) in the default browser.""" + url = f"{tux.server_url}/static/agent-lexical.html?agentId={tux.agent_id}" + if tux.jupyter_url: + # Forward Jupyter connection info so the page can reach the kernel + import urllib.parse + base = tux.jupyter_url.split("?")[0].rstrip("/") + query = tux.jupyter_url.split("?")[1] if "?" in tux.jupyter_url else None + token = "" + if query: + params = urllib.parse.parse_qs(query) + token = params.get("token", [""])[0] + url += f"&jupyterBaseUrl={urllib.parse.quote(base, safe='')}" + if token: + url += f"&jupyterToken={urllib.parse.quote(token, safe='')}" + tux.console.print(f" Opening [bold cyan]{url}[/bold cyan]") + webbrowser.open(url) + return None