666 lines
25 KiB
Python
666 lines
25 KiB
Python
"""Textual UI application for deepagents-cli."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import subprocess
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
|
|
from textual.app import App
|
|
from textual.binding import Binding, BindingType
|
|
from textual.containers import Container, VerticalScroll
|
|
from textual.css.query import NoMatches
|
|
from textual.events import MouseUp # noqa: TC002 - used in type annotation
|
|
from textual.widgets import Static # noqa: TC002 - used at runtime
|
|
|
|
from deepagents_cli.clipboard import copy_selection_to_clipboard
|
|
from deepagents_cli.textual_adapter import TextualUIAdapter, execute_task_textual
|
|
from deepagents_cli.widgets.approval import ApprovalMenu
|
|
from deepagents_cli.widgets.chat_input import ChatInput
|
|
from deepagents_cli.widgets.loading import LoadingWidget
|
|
from deepagents_cli.widgets.messages import (
|
|
AssistantMessage,
|
|
ErrorMessage,
|
|
SystemMessage,
|
|
ToolCallMessage,
|
|
UserMessage,
|
|
)
|
|
from deepagents_cli.widgets.status import StatusBar
|
|
from deepagents_cli.widgets.welcome import WelcomeBanner
|
|
|
|
if TYPE_CHECKING:
|
|
from langgraph.pregel import Pregel
|
|
from textual.app import ComposeResult
|
|
from textual.worker import Worker
|
|
|
|
|
|
class TextualTokenTracker:
|
|
"""Token tracker that updates the status bar."""
|
|
|
|
def __init__(self, update_callback: callable) -> None:
|
|
"""Initialize with a callback to update the display."""
|
|
self._update_callback = update_callback
|
|
self.current_context = 0
|
|
|
|
def add(self, input_tokens: int, output_tokens: int) -> None: # noqa: ARG002
|
|
"""Update token count from a response."""
|
|
self.current_context = input_tokens
|
|
self._update_callback(input_tokens)
|
|
|
|
def reset(self) -> None:
|
|
"""Reset token count."""
|
|
self.current_context = 0
|
|
self._update_callback(0)
|
|
|
|
|
|
class TextualSessionState:
|
|
"""Session state for the Textual app."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
auto_approve: bool = False,
|
|
thread_id: str | None = None,
|
|
) -> None:
|
|
"""Initialize session state.
|
|
|
|
Args:
|
|
auto_approve: Whether to auto-approve tool calls
|
|
thread_id: Optional thread ID (generates 8-char hex if not provided)
|
|
"""
|
|
self.auto_approve = auto_approve
|
|
self.thread_id = thread_id if thread_id else uuid.uuid4().hex[:8]
|
|
|
|
def reset_thread(self) -> str:
|
|
"""Reset to a new thread. Returns the new thread_id."""
|
|
self.thread_id = uuid.uuid4().hex[:8]
|
|
return self.thread_id
|
|
|
|
|
|
class DeepAgentsApp(App):
|
|
"""Main Textual application for deepagents-cli."""
|
|
|
|
TITLE = "DeepAgents"
|
|
CSS_PATH = "app.tcss"
|
|
ENABLE_COMMAND_PALETTE = False
|
|
|
|
# Slow down scroll speed (default is 3 lines per scroll event)
|
|
# Using 0.25 to require 4 scroll events per line - very smooth
|
|
SCROLL_SENSITIVITY_Y = 0.25
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
|
|
Binding("ctrl+c", "quit_or_interrupt", "Quit/Interrupt", show=False),
|
|
Binding("ctrl+d", "quit_app", "Quit", show=False, priority=True),
|
|
Binding("ctrl+t", "toggle_auto_approve", "Toggle Auto-Approve", show=False),
|
|
Binding(
|
|
"shift+tab", "toggle_auto_approve", "Toggle Auto-Approve", show=False, priority=True
|
|
),
|
|
Binding("ctrl+o", "toggle_tool_output", "Toggle Tool Output", show=False),
|
|
# Approval menu keys (handled at App level for reliability)
|
|
Binding("up", "approval_up", "Up", show=False),
|
|
Binding("k", "approval_up", "Up", show=False),
|
|
Binding("down", "approval_down", "Down", show=False),
|
|
Binding("j", "approval_down", "Down", show=False),
|
|
Binding("enter", "approval_select", "Select", show=False),
|
|
Binding("y", "approval_yes", "Yes", show=False),
|
|
Binding("1", "approval_yes", "Yes", show=False),
|
|
Binding("n", "approval_no", "No", show=False),
|
|
Binding("2", "approval_no", "No", show=False),
|
|
Binding("a", "approval_auto", "Auto", show=False),
|
|
Binding("3", "approval_auto", "Auto", show=False),
|
|
]
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
agent: Pregel | None = None,
|
|
assistant_id: str | None = None,
|
|
backend: Any = None, # noqa: ANN401 # CompositeBackend
|
|
auto_approve: bool = False,
|
|
cwd: str | Path | None = None,
|
|
thread_id: str | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Initialize the DeepAgents application.
|
|
|
|
Args:
|
|
agent: Pre-configured LangGraph agent (optional for standalone mode)
|
|
assistant_id: Agent identifier for memory storage
|
|
backend: Backend for file operations
|
|
auto_approve: Whether to start with auto-approve enabled
|
|
cwd: Current working directory to display
|
|
thread_id: Optional thread ID for session persistence
|
|
**kwargs: Additional arguments passed to parent
|
|
"""
|
|
super().__init__(**kwargs)
|
|
self._agent = agent
|
|
self._assistant_id = assistant_id
|
|
self._backend = backend
|
|
self._auto_approve = auto_approve
|
|
self._cwd = str(cwd) if cwd else str(Path.cwd())
|
|
# Avoid collision with App._thread_id
|
|
self._lc_thread_id = thread_id
|
|
self._status_bar: StatusBar | None = None
|
|
self._chat_input: ChatInput | None = None
|
|
self._quit_pending = False
|
|
self._session_state: TextualSessionState | None = None
|
|
self._ui_adapter: TextualUIAdapter | None = None
|
|
self._pending_approval: asyncio.Future | None = None
|
|
self._pending_approval_widget: Any = None
|
|
# Agent task tracking for interruption
|
|
self._agent_worker: Worker[None] | None = None
|
|
self._agent_running = False
|
|
self._loading_widget: LoadingWidget | None = None
|
|
self._token_tracker: TextualTokenTracker | None = None
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose the application layout."""
|
|
# Main chat area with scrollable messages
|
|
with VerticalScroll(id="chat"):
|
|
yield WelcomeBanner(id="welcome-banner")
|
|
yield Container(id="messages") # Container can have children mounted
|
|
|
|
# Bottom app container - holds either ChatInput OR ApprovalMenu (swapped)
|
|
# This is OUTSIDE VerticalScroll so arrow keys work in approval
|
|
with Container(id="bottom-app-container"):
|
|
yield ChatInput(cwd=self._cwd, id="input-area")
|
|
|
|
# Status bar at bottom
|
|
yield StatusBar(cwd=self._cwd, id="status-bar")
|
|
|
|
async def on_mount(self) -> None:
|
|
"""Initialize components after mount."""
|
|
self._status_bar = self.query_one("#status-bar", StatusBar)
|
|
self._chat_input = self.query_one("#input-area", ChatInput)
|
|
|
|
# Set initial auto-approve state
|
|
if self._auto_approve:
|
|
self._status_bar.set_auto_approve(enabled=True)
|
|
|
|
# Create session state
|
|
self._session_state = TextualSessionState(
|
|
auto_approve=self._auto_approve,
|
|
thread_id=self._lc_thread_id,
|
|
)
|
|
|
|
# Create token tracker that updates status bar
|
|
self._token_tracker = TextualTokenTracker(self._update_tokens)
|
|
|
|
# Create UI adapter if agent is provided
|
|
if self._agent:
|
|
self._ui_adapter = TextualUIAdapter(
|
|
mount_message=self._mount_message,
|
|
update_status=self._update_status,
|
|
request_approval=self._request_approval,
|
|
on_auto_approve_enabled=self._on_auto_approve_enabled,
|
|
scroll_to_bottom=self._scroll_chat_to_bottom,
|
|
)
|
|
self._ui_adapter.set_token_tracker(self._token_tracker)
|
|
|
|
# Focus the input (autocomplete is now built into ChatInput)
|
|
self._chat_input.focus_input()
|
|
|
|
def _update_status(self, message: str) -> None:
|
|
"""Update the status bar with a message."""
|
|
if self._status_bar:
|
|
self._status_bar.set_status_message(message)
|
|
|
|
def _update_tokens(self, count: int) -> None:
|
|
"""Update the token count in status bar."""
|
|
if self._status_bar:
|
|
self._status_bar.set_tokens(count)
|
|
|
|
def _scroll_chat_to_bottom(self) -> None:
|
|
"""Scroll the chat area to the bottom.
|
|
|
|
Uses anchor() for smoother streaming - keeps scroll locked to bottom
|
|
as new content is added without causing visual jumps.
|
|
"""
|
|
try:
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
# anchor() locks scroll to bottom and auto-scrolls as content grows
|
|
# Much smoother than calling scroll_end() on every chunk
|
|
chat.anchor()
|
|
except NoMatches:
|
|
pass
|
|
|
|
async def _request_approval(
|
|
self,
|
|
action_request: Any, # noqa: ANN401
|
|
assistant_id: str | None,
|
|
) -> asyncio.Future:
|
|
"""Request user approval inline in the messages area.
|
|
|
|
Returns a Future that resolves to the user's decision.
|
|
Mounts ApprovalMenu in the messages area (inline with chat).
|
|
ChatInput stays visible - user can still see it.
|
|
|
|
If another approval is already pending, queue this one.
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
result_future: asyncio.Future = loop.create_future()
|
|
|
|
# If there's already a pending approval, wait for it to complete first
|
|
if self._pending_approval_widget is not None:
|
|
while self._pending_approval_widget is not None: # noqa: ASYNC110
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Create menu with unique ID to avoid conflicts
|
|
unique_id = f"approval-menu-{uuid.uuid4().hex[:8]}"
|
|
menu = ApprovalMenu(action_request, assistant_id, id=unique_id)
|
|
menu.set_future(result_future)
|
|
|
|
# Store reference
|
|
self._pending_approval_widget = menu
|
|
|
|
# Pause the loading spinner during approval
|
|
if self._loading_widget:
|
|
self._loading_widget.pause("Awaiting decision")
|
|
|
|
# Update status to show we're waiting for approval
|
|
self._update_status("Waiting for approval...")
|
|
|
|
# Mount approval inline in messages area (not replacing ChatInput)
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
await messages.mount(menu)
|
|
self._scroll_chat_to_bottom()
|
|
# Focus approval menu
|
|
self.call_after_refresh(menu.focus)
|
|
except Exception as e: # noqa: BLE001
|
|
self._pending_approval_widget = None
|
|
if not result_future.done():
|
|
result_future.set_exception(e)
|
|
|
|
return result_future
|
|
|
|
def _on_auto_approve_enabled(self) -> None:
|
|
"""Callback when auto-approve mode is enabled via HITL."""
|
|
self._auto_approve = True
|
|
if self._status_bar:
|
|
self._status_bar.set_auto_approve(enabled=True)
|
|
if self._session_state:
|
|
self._session_state.auto_approve = True
|
|
|
|
async def on_chat_input_submitted(self, event: ChatInput.Submitted) -> None:
|
|
"""Handle submitted input from ChatInput widget."""
|
|
value = event.value
|
|
mode = event.mode
|
|
|
|
# Reset quit pending state on any input
|
|
self._quit_pending = False
|
|
|
|
# Handle different modes
|
|
if mode == "bash":
|
|
# Bash command - strip the ! prefix
|
|
await self._handle_bash_command(value.removeprefix("!"))
|
|
elif mode == "command":
|
|
# Slash command
|
|
await self._handle_command(value)
|
|
else:
|
|
# Normal message - will be sent to agent
|
|
await self._handle_user_message(value)
|
|
|
|
def on_chat_input_mode_changed(self, event: ChatInput.ModeChanged) -> None:
|
|
"""Update status bar when input mode changes."""
|
|
if self._status_bar:
|
|
self._status_bar.set_mode(event.mode)
|
|
|
|
async def on_approval_menu_decided(
|
|
self,
|
|
event: Any, # noqa: ANN401, ARG002
|
|
) -> None:
|
|
"""Handle approval menu decision - remove from messages and refocus input."""
|
|
# Remove ApprovalMenu using stored reference
|
|
if self._pending_approval_widget:
|
|
await self._pending_approval_widget.remove()
|
|
self._pending_approval_widget = None
|
|
|
|
# Resume the loading spinner after approval
|
|
if self._loading_widget:
|
|
self._loading_widget.resume()
|
|
|
|
# Clear status message
|
|
self._update_status("")
|
|
|
|
# Refocus the chat input
|
|
if self._chat_input:
|
|
self.call_after_refresh(self._chat_input.focus_input)
|
|
|
|
async def _handle_bash_command(self, command: str) -> None:
|
|
"""Handle a bash command (! prefix).
|
|
|
|
Args:
|
|
command: The bash command to execute
|
|
"""
|
|
# Mount user message showing the bash command
|
|
await self._mount_message(UserMessage(f"!{command}"))
|
|
|
|
# Execute the bash command (shell=True is intentional for user-requested bash)
|
|
try:
|
|
result = await asyncio.to_thread( # noqa: S604
|
|
subprocess.run,
|
|
command,
|
|
shell=True,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=self._cwd,
|
|
timeout=60,
|
|
)
|
|
output = result.stdout.strip()
|
|
if result.stderr:
|
|
output += f"\n[stderr]\n{result.stderr.strip()}"
|
|
|
|
if output:
|
|
# Display output as assistant message (uses markdown for code blocks)
|
|
msg = AssistantMessage(f"```\n{output}\n```")
|
|
await self._mount_message(msg)
|
|
await msg.write_initial_content()
|
|
else:
|
|
await self._mount_message(SystemMessage("Command completed (no output)"))
|
|
|
|
if result.returncode != 0:
|
|
await self._mount_message(ErrorMessage(f"Exit code: {result.returncode}"))
|
|
|
|
# Scroll to show the output
|
|
self._scroll_chat_to_bottom()
|
|
|
|
except subprocess.TimeoutExpired:
|
|
await self._mount_message(ErrorMessage("Command timed out (60s limit)"))
|
|
except OSError as e:
|
|
await self._mount_message(ErrorMessage(str(e)))
|
|
|
|
async def _handle_command(self, command: str) -> None:
|
|
"""Handle a slash command.
|
|
|
|
Args:
|
|
command: The slash command (including /)
|
|
"""
|
|
cmd = command.lower().strip()
|
|
|
|
if cmd in ("/quit", "/exit", "/q"):
|
|
self.exit()
|
|
elif cmd == "/help":
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(
|
|
SystemMessage("Commands: /quit, /clear, /tokens, /threads, /help")
|
|
)
|
|
elif cmd == "/clear":
|
|
await self._clear_messages()
|
|
# Reset thread to start fresh conversation
|
|
if self._session_state:
|
|
new_thread_id = self._session_state.reset_thread()
|
|
await self._mount_message(SystemMessage(f"Started new session: {new_thread_id}"))
|
|
elif cmd == "/threads":
|
|
await self._mount_message(UserMessage(command))
|
|
if self._session_state:
|
|
await self._mount_message(
|
|
SystemMessage(f"Current session: {self._session_state.thread_id}")
|
|
)
|
|
else:
|
|
await self._mount_message(SystemMessage("No active session"))
|
|
elif cmd == "/tokens":
|
|
await self._mount_message(UserMessage(command))
|
|
if self._token_tracker and self._token_tracker.current_context > 0:
|
|
count = self._token_tracker.current_context
|
|
if count >= 1000:
|
|
formatted = f"{count / 1000:.1f}K"
|
|
else:
|
|
formatted = str(count)
|
|
await self._mount_message(SystemMessage(f"Current context: {formatted} tokens"))
|
|
else:
|
|
await self._mount_message(SystemMessage("No token usage yet"))
|
|
else:
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(SystemMessage(f"Unknown command: {cmd}"))
|
|
|
|
async def _handle_user_message(self, message: str) -> None:
|
|
"""Handle a user message to send to the agent.
|
|
|
|
Args:
|
|
message: The user's message
|
|
"""
|
|
# Mount the user message
|
|
await self._mount_message(UserMessage(message))
|
|
|
|
# Check if agent is available
|
|
if self._agent and self._ui_adapter and self._session_state:
|
|
# Show loading widget
|
|
self._loading_widget = LoadingWidget("Thinking")
|
|
await self._mount_message(self._loading_widget)
|
|
self._agent_running = True
|
|
|
|
# Disable cursor blink while agent is working
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=False)
|
|
|
|
# Use run_worker to avoid blocking the main event loop
|
|
# This allows the UI to remain responsive during agent execution
|
|
self._agent_worker = self.run_worker(
|
|
self._run_agent_task(message),
|
|
exclusive=False,
|
|
)
|
|
else:
|
|
await self._mount_message(
|
|
SystemMessage("Agent not configured. Run with --agent flag or use standalone mode.")
|
|
)
|
|
|
|
async def _run_agent_task(self, message: str) -> None:
|
|
"""Run the agent task in a background worker.
|
|
|
|
This runs in a worker thread so the main event loop stays responsive.
|
|
"""
|
|
try:
|
|
await execute_task_textual(
|
|
user_input=message,
|
|
agent=self._agent,
|
|
assistant_id=self._assistant_id,
|
|
session_state=self._session_state,
|
|
adapter=self._ui_adapter,
|
|
backend=self._backend,
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
await self._mount_message(ErrorMessage(f"Agent error: {e}"))
|
|
finally:
|
|
# Clean up loading widget and agent state
|
|
await self._cleanup_agent_task()
|
|
|
|
async def _cleanup_agent_task(self) -> None:
|
|
"""Clean up after agent task completes or is cancelled."""
|
|
self._agent_running = False
|
|
self._agent_worker = None
|
|
|
|
# Remove loading widget if present
|
|
if self._loading_widget:
|
|
with contextlib.suppress(Exception):
|
|
await self._loading_widget.remove()
|
|
self._loading_widget = None
|
|
|
|
# Re-enable cursor blink now that agent is done
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=True)
|
|
|
|
async def _mount_message(self, widget: Static) -> None:
|
|
"""Mount a message widget to the messages area.
|
|
|
|
Args:
|
|
widget: The message widget to mount
|
|
"""
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
await messages.mount(widget)
|
|
# Scroll to bottom
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
chat.scroll_end(animate=False)
|
|
except NoMatches:
|
|
pass
|
|
|
|
async def _clear_messages(self) -> None:
|
|
"""Clear the messages area."""
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
await messages.remove_children()
|
|
except NoMatches:
|
|
# Widget not found - can happen during shutdown
|
|
pass
|
|
|
|
def action_quit_or_interrupt(self) -> None:
|
|
"""Handle Ctrl+C - interrupt agent, reject approval, or quit on double press.
|
|
|
|
Priority order:
|
|
1. If agent is running, interrupt it (preserve input)
|
|
2. If approval menu is active, reject it
|
|
3. If double press (quit_pending), quit
|
|
4. Otherwise show quit hint
|
|
"""
|
|
# If agent is running, interrupt it
|
|
if self._agent_running and self._agent_worker:
|
|
self._agent_worker.cancel()
|
|
self._quit_pending = False
|
|
return
|
|
|
|
# If approval menu is active, reject it
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_reject()
|
|
self._quit_pending = False
|
|
return
|
|
|
|
# Double Ctrl+C to quit
|
|
if self._quit_pending:
|
|
self.exit()
|
|
else:
|
|
self._quit_pending = True
|
|
self.notify("Press Ctrl+C again to quit", timeout=3)
|
|
|
|
def action_interrupt(self) -> None:
|
|
"""Handle escape key - interrupt agent or reject approval.
|
|
|
|
This is the primary way to stop a running agent.
|
|
"""
|
|
# If agent is running, interrupt it
|
|
if self._agent_running and self._agent_worker:
|
|
self._agent_worker.cancel()
|
|
return
|
|
|
|
# If approval menu is active, reject it
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_reject()
|
|
|
|
def action_quit_app(self) -> None:
|
|
"""Handle quit action (Ctrl+D)."""
|
|
self.exit()
|
|
|
|
def action_toggle_auto_approve(self) -> None:
|
|
"""Toggle auto-approve mode."""
|
|
self._auto_approve = not self._auto_approve
|
|
if self._status_bar:
|
|
self._status_bar.set_auto_approve(enabled=self._auto_approve)
|
|
if self._session_state:
|
|
self._session_state.auto_approve = self._auto_approve
|
|
|
|
def action_toggle_tool_output(self) -> None:
|
|
"""Toggle expand/collapse of the most recent tool output."""
|
|
# Find all tool messages with output, get the most recent one
|
|
try:
|
|
tool_messages = list(self.query(ToolCallMessage))
|
|
# Find ones with output, toggle the most recent
|
|
for tool_msg in reversed(tool_messages):
|
|
if tool_msg.has_output:
|
|
tool_msg.toggle_output()
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
# Approval menu action handlers (delegated from App-level bindings)
|
|
# NOTE: These only activate when approval widget is pending AND input is not focused
|
|
def action_approval_up(self) -> None:
|
|
"""Handle up arrow in approval menu."""
|
|
# Only handle if approval is active (input handles its own up for history/completion)
|
|
if self._pending_approval_widget and not self._is_input_focused():
|
|
self._pending_approval_widget.action_move_up()
|
|
|
|
def action_approval_down(self) -> None:
|
|
"""Handle down arrow in approval menu."""
|
|
if self._pending_approval_widget and not self._is_input_focused():
|
|
self._pending_approval_widget.action_move_down()
|
|
|
|
def action_approval_select(self) -> None:
|
|
"""Handle enter in approval menu."""
|
|
# Only handle if approval is active AND input is not focused
|
|
if self._pending_approval_widget and not self._is_input_focused():
|
|
self._pending_approval_widget.action_select()
|
|
|
|
def _is_input_focused(self) -> bool:
|
|
"""Check if the chat input (or its text area) has focus."""
|
|
if not self._chat_input:
|
|
return False
|
|
focused = self.focused
|
|
if focused is None:
|
|
return False
|
|
# Check if focused widget is the text area inside chat input
|
|
return focused.id == "chat-input" or focused in self._chat_input.walk_children()
|
|
|
|
def action_approval_yes(self) -> None:
|
|
"""Handle yes/1 in approval menu."""
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_approve()
|
|
|
|
def action_approval_no(self) -> None:
|
|
"""Handle no/2 in approval menu."""
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_reject()
|
|
|
|
def action_approval_auto(self) -> None:
|
|
"""Handle auto/3 in approval menu."""
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_auto()
|
|
|
|
def action_approval_escape(self) -> None:
|
|
"""Handle escape in approval menu - reject."""
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_reject()
|
|
|
|
def on_mouse_up(self, event: MouseUp) -> None: # noqa: ARG002
|
|
"""Copy selection to clipboard on mouse release."""
|
|
copy_selection_to_clipboard(self)
|
|
|
|
|
|
async def run_textual_app(
|
|
*,
|
|
agent: Pregel | None = None,
|
|
assistant_id: str | None = None,
|
|
backend: Any = None, # noqa: ANN401 # CompositeBackend
|
|
auto_approve: bool = False,
|
|
cwd: str | Path | None = None,
|
|
thread_id: str | None = None,
|
|
) -> None:
|
|
"""Run the Textual application.
|
|
|
|
Args:
|
|
agent: Pre-configured LangGraph agent (optional)
|
|
assistant_id: Agent identifier for memory storage
|
|
backend: Backend for file operations
|
|
auto_approve: Whether to start with auto-approve enabled
|
|
cwd: Current working directory to display
|
|
thread_id: Optional thread ID for session persistence
|
|
"""
|
|
app = DeepAgentsApp(
|
|
agent=agent,
|
|
assistant_id=assistant_id,
|
|
backend=backend,
|
|
auto_approve=auto_approve,
|
|
cwd=cwd,
|
|
thread_id=thread_id,
|
|
)
|
|
await app.run_async()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import asyncio
|
|
|
|
asyncio.run(run_textual_app())
|