1205 lines
44 KiB
Python
1205 lines
44 KiB
Python
# mypy: ignore-errors
|
|
# standalone example with sys.path sibling imports that mypy cannot follow
|
|
"""Textual TUI for the Temporal Sandbox agent conversation client.
|
|
|
|
Sessions are managed entirely via Temporal — no filesystem persistence.
|
|
A central SessionManagerWorkflow tracks all active agent sessions. The
|
|
TUI connects to it on startup to list, create, resume, and destroy sessions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from datetime import timezone
|
|
from pathlib import Path
|
|
|
|
from rich.markdown import Markdown
|
|
from rich.text import Text
|
|
from temporal_sandbox_agent import TurnState
|
|
from temporal_session_manager import (
|
|
MANAGER_WORKFLOW_ID,
|
|
BackendConfig,
|
|
CreateSessionRequest,
|
|
DaytonaBackendConfig,
|
|
DockerBackendConfig,
|
|
E2BBackendConfig,
|
|
ForkSessionRequest,
|
|
LocalBackendConfig,
|
|
RenameRequest,
|
|
SessionInfo,
|
|
SessionManagerWorkflow,
|
|
SwitchBackendRequest,
|
|
)
|
|
from temporalio.client import Client, WorkflowHandle
|
|
from temporalio.contrib.openai_agents import OpenAIAgentsPlugin
|
|
from temporalio.exceptions import WorkflowAlreadyStartedError
|
|
from textual import work
|
|
from textual.app import App, ComposeResult
|
|
from textual.binding import Binding
|
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
from textual.screen import ModalScreen
|
|
from textual.widgets import (
|
|
Button,
|
|
Footer,
|
|
Header,
|
|
Input,
|
|
OptionList,
|
|
Static,
|
|
Tree,
|
|
)
|
|
from textual.widgets.option_list import Option
|
|
|
|
NEW_SESSION_ID = "__new__"
|
|
NEW_FROM_SNAPSHOT_ID = "__new_from_snapshot__"
|
|
|
|
SLASH_COMMANDS = [
|
|
("/title <name>", "Rename the current session"),
|
|
("/fork [title]", "Fork this session into a new one"),
|
|
("/switch [backend]", "Switch sandbox backend (daytona/local)"),
|
|
("/done", "Exit the session"),
|
|
]
|
|
|
|
|
|
class ToolDetailModal(ModalScreen):
|
|
"""Full-screen modal showing tool call command and output."""
|
|
|
|
BINDINGS = [("escape", "dismiss", "Close")]
|
|
|
|
def __init__(self, title: str, body: str) -> None:
|
|
super().__init__()
|
|
self._title = title
|
|
self._body = body
|
|
|
|
def compose(self) -> ComposeResult:
|
|
with Vertical(id="tool-modal"):
|
|
with Vertical(id="tool-modal-box"):
|
|
yield Static(self._title, id="tool-modal-title")
|
|
with VerticalScroll(id="tool-modal-scroll"):
|
|
yield Static(self._body, id="tool-modal-body")
|
|
|
|
def action_dismiss(self) -> None:
|
|
self.app.pop_screen()
|
|
|
|
|
|
class ToolLine(Static):
|
|
"""A clickable one-line tool call summary in the chat flow."""
|
|
|
|
def __init__(self, title: str, body: str, **kwargs) -> None:
|
|
super().__init__(title, classes="tool-line", **kwargs)
|
|
self._title = title
|
|
self._body = body
|
|
|
|
def on_click(self) -> None:
|
|
self.app.push_screen(ToolDetailModal(self._title, self._body))
|
|
|
|
|
|
class ConversationApp(App):
|
|
"""Textual chat UI backed by Temporal workflows.
|
|
|
|
On startup the app connects to the session manager, presents a session
|
|
picker, and then enters the chat loop. On exit the user chooses to
|
|
keep the session alive (detach) or destroy it.
|
|
"""
|
|
|
|
TITLE = "Sandbox Agent (live)"
|
|
SUB_TITLE = "Temporal Workflow"
|
|
|
|
CSS = """
|
|
#chat {
|
|
height: 1fr;
|
|
border: round $accent;
|
|
margin: 1 2;
|
|
padding: 1 2;
|
|
scrollbar-gutter: stable;
|
|
}
|
|
#chat > Static {
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
.tool-line {
|
|
height: 1;
|
|
padding: 0 1;
|
|
color: $text-muted;
|
|
}
|
|
.tool-line:hover {
|
|
background: $surface;
|
|
color: $text;
|
|
}
|
|
#tool-modal {
|
|
align: center middle;
|
|
}
|
|
#tool-modal-box {
|
|
width: 90%;
|
|
height: 80%;
|
|
border: round $accent;
|
|
background: $surface;
|
|
padding: 1 2;
|
|
}
|
|
#tool-modal-title {
|
|
height: 1;
|
|
width: 1fr;
|
|
text-style: bold;
|
|
margin: 0 0 1 0;
|
|
}
|
|
#tool-modal-scroll {
|
|
height: 1fr;
|
|
}
|
|
#tool-modal-body {
|
|
height: auto;
|
|
}
|
|
#status-bar {
|
|
height: 1;
|
|
padding: 0 2;
|
|
background: $surface;
|
|
color: $text;
|
|
layout: horizontal;
|
|
}
|
|
#liveness {
|
|
width: auto;
|
|
}
|
|
#activity {
|
|
width: auto;
|
|
margin: 0 0 0 2;
|
|
}
|
|
Input {
|
|
margin: 0 2 1 2;
|
|
}
|
|
#slash-menu {
|
|
display: none;
|
|
height: auto;
|
|
max-height: 8;
|
|
margin: 0 2;
|
|
background: $surface;
|
|
border: round $accent;
|
|
}
|
|
#session-picker {
|
|
height: 1fr;
|
|
margin: 1 2;
|
|
border: round $accent;
|
|
padding: 1;
|
|
}
|
|
#approval-bar {
|
|
height: auto;
|
|
margin: 0 2 1 2;
|
|
layout: vertical;
|
|
}
|
|
#approval-label {
|
|
width: 1fr;
|
|
padding: 0 1 1 1;
|
|
}
|
|
#approval-buttons {
|
|
height: auto;
|
|
align-horizontal: center;
|
|
}
|
|
#approval-buttons Button {
|
|
margin: 0 1;
|
|
}
|
|
#exit-bar {
|
|
height: auto;
|
|
margin: 0 2 1 2;
|
|
layout: vertical;
|
|
}
|
|
#exit-label {
|
|
width: 1fr;
|
|
padding: 0 1 1 1;
|
|
}
|
|
#exit-buttons {
|
|
height: auto;
|
|
align-horizontal: center;
|
|
}
|
|
#exit-buttons Button {
|
|
margin: 0 1;
|
|
}
|
|
#fork-bar {
|
|
height: auto;
|
|
margin: 0 2 1 2;
|
|
layout: vertical;
|
|
}
|
|
#fork-label {
|
|
width: 1fr;
|
|
padding: 0 1 1 1;
|
|
}
|
|
#fork-buttons {
|
|
height: auto;
|
|
align-horizontal: center;
|
|
}
|
|
#fork-buttons Button {
|
|
margin: 0 1;
|
|
}
|
|
#snapshot-picker {
|
|
height: 1fr;
|
|
margin: 1 2;
|
|
border: round $accent;
|
|
padding: 1;
|
|
}
|
|
#backend-picker {
|
|
height: auto;
|
|
margin: 1 2;
|
|
layout: vertical;
|
|
}
|
|
#backend-label {
|
|
width: 1fr;
|
|
padding: 0 1 1 1;
|
|
}
|
|
#backend-buttons {
|
|
height: auto;
|
|
align-horizontal: center;
|
|
}
|
|
#backend-buttons Button {
|
|
margin: 0 1;
|
|
}
|
|
#workspace-picker {
|
|
height: auto;
|
|
margin: 1 2;
|
|
layout: vertical;
|
|
}
|
|
#workspace-label {
|
|
width: 1fr;
|
|
padding: 0 1 1 1;
|
|
}
|
|
#workspace-input {
|
|
margin: 0 2 1 2;
|
|
}
|
|
#workspace-buttons {
|
|
height: auto;
|
|
align-horizontal: center;
|
|
}
|
|
#workspace-buttons Button {
|
|
margin: 0 1;
|
|
}
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding("ctrl+c", "quit_graceful", "Quit", priority=True),
|
|
]
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
workflow_cls: type,
|
|
task_queue: str,
|
|
cwd: str,
|
|
) -> None:
|
|
super().__init__()
|
|
self._workflow_cls = workflow_cls
|
|
self._task_queue = task_queue
|
|
self._cwd = cwd
|
|
self._handle: WorkflowHandle | None = None
|
|
self._manager_handle: WorkflowHandle | None = None
|
|
self._temporal_client: Client | None = None
|
|
self._current_workflow_id: str | None = None
|
|
self._poll_timer = None
|
|
self._last_paused: bool = False
|
|
self._pending_fork_title: str | None = None
|
|
self._cached_sessions: list[SessionInfo] = []
|
|
self._current_backend: str = "daytona"
|
|
self._current_turn_id: int = 0
|
|
self._pending_backend_action: str = "new_session" # "new_session" or "switch"
|
|
|
|
async def _backfill_snapshot_ids(self, sessions: list[SessionInfo]) -> None:
|
|
"""Query each workflow's live snapshot ID concurrently.
|
|
|
|
Fills in ``snapshot_id`` on SessionInfo objects that don't already
|
|
have one (e.g. sessions created fresh, before any fork/persist).
|
|
"""
|
|
assert self._temporal_client is not None
|
|
missing = [s for s in sessions if not s.snapshot_id]
|
|
if not missing:
|
|
return
|
|
|
|
async def _fetch(s: SessionInfo) -> None:
|
|
try:
|
|
handle = self._temporal_client.get_workflow_handle(s.workflow_id) # type: ignore[union-attr]
|
|
sid = await handle.query(self._workflow_cls.get_snapshot_id)
|
|
if sid:
|
|
s.snapshot_id = sid
|
|
except Exception:
|
|
pass
|
|
|
|
await asyncio.gather(*[_fetch(s) for s in missing])
|
|
|
|
# -- Status helpers -----------------------------------------------------
|
|
|
|
def _set_liveness(self, text: str | Text) -> None:
|
|
"""Update the persistent liveness indicator (Active / Paused)."""
|
|
self.query_one("#liveness", Static).update(text)
|
|
|
|
def _set_activity(self, text: str | Text = "") -> None:
|
|
"""Update the transient activity indicator (Thinking / Approval / Error).
|
|
|
|
Pass empty string to clear."""
|
|
self.query_one("#activity", Static).update(text)
|
|
|
|
# -- Chat helpers -------------------------------------------------------
|
|
|
|
def _chat_write(self, content) -> None:
|
|
"""Append a renderable to the chat scroll area."""
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
chat.mount(Static(content))
|
|
chat.scroll_end(animate=False)
|
|
|
|
def _chat_clear(self) -> None:
|
|
"""Remove all children from the chat scroll area."""
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
chat.remove_children()
|
|
|
|
@staticmethod
|
|
def _tool_call_title(tc) -> str:
|
|
"""Format a one-line title for a tool call Collapsible."""
|
|
icon = "\u2713" if tc.status == "completed" else "\u23f3"
|
|
full_text = tc.arguments
|
|
try:
|
|
args = json.loads(tc.arguments)
|
|
if "commands" in args:
|
|
cmds = args["commands"]
|
|
full_text = "; ".join(cmds) if cmds else "(empty)"
|
|
elif "command" in args:
|
|
full_text = args["command"]
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
lines = full_text.split("\n")
|
|
first_line = lines[0]
|
|
if len(first_line) > 80:
|
|
first_line = first_line[:77] + "..."
|
|
extra = len(lines) - 1
|
|
suffix = f" [... +{extra} lines]" if extra > 0 else ""
|
|
return f"{icon} {tc.tool_name}: {first_line}{suffix}"
|
|
|
|
@staticmethod
|
|
def _tool_call_body(tc) -> str:
|
|
"""Format the expanded body of a tool call Collapsible."""
|
|
parts = []
|
|
try:
|
|
args = json.loads(tc.arguments)
|
|
parts.append(json.dumps(args, indent=2))
|
|
except (json.JSONDecodeError, TypeError):
|
|
parts.append(tc.arguments)
|
|
if tc.status == "completed":
|
|
output = tc.output or "(empty)"
|
|
parts.append(f"\n--- output ---\n{output}")
|
|
elif tc.status == "running":
|
|
parts.append("\n\u23f3 Running...")
|
|
else:
|
|
parts.append("\n\u23f3 Pending...")
|
|
return "\n".join(parts)
|
|
|
|
async def _render_live_tool_calls(self, state: TurnState) -> None:
|
|
"""Create or update ToolLine widgets for live tool calls."""
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
for tc in state.tool_calls:
|
|
widget_id = "tc_" + "".join(c if c.isalnum() else "_" for c in tc.call_id)
|
|
title = self._tool_call_title(tc)
|
|
body = self._tool_call_body(tc)
|
|
existing = self.query(f"#{widget_id}")
|
|
if existing:
|
|
line = existing.first(ToolLine)
|
|
line.update(title)
|
|
line._body = body
|
|
else:
|
|
await chat.mount(ToolLine(title, body, id=widget_id))
|
|
chat.scroll_end(animate=False)
|
|
|
|
# -- Layout -------------------------------------------------------------
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Header()
|
|
yield Tree("Sessions", id="session-picker")
|
|
yield Tree("Pick a source session", id="snapshot-picker")
|
|
with Vertical(id="backend-picker"):
|
|
yield Static("Choose sandbox backend:", id="backend-label")
|
|
with Horizontal(id="backend-buttons"):
|
|
yield Button("Daytona (cloud)", id="btn-backend-daytona", variant="primary")
|
|
yield Button("Docker", id="btn-backend-docker", variant="primary")
|
|
yield Button("E2B (cloud)", id="btn-backend-e2b", variant="primary")
|
|
yield Button("Local (unix)", id="btn-backend-local", variant="warning")
|
|
with Vertical(id="workspace-picker"):
|
|
yield Static(
|
|
"Workspace root (agent files will be created here):",
|
|
id="workspace-label",
|
|
)
|
|
yield Input(id="workspace-input", placeholder="/absolute/path/to/workspace")
|
|
with Horizontal(id="workspace-buttons"):
|
|
yield Button("Accept", id="btn-workspace-accept", variant="success")
|
|
yield Button("Cancel", id="btn-workspace-cancel", variant="error")
|
|
yield VerticalScroll(id="chat")
|
|
with Vertical(id="approval-bar"):
|
|
yield Static("", id="approval-label")
|
|
with Horizontal(id="approval-buttons"):
|
|
yield Button("Approve", id="btn-approve", variant="success")
|
|
yield Button("Deny", id="btn-deny", variant="error")
|
|
with Vertical(id="fork-bar"):
|
|
yield Static("", id="fork-label")
|
|
with Horizontal(id="fork-buttons"):
|
|
yield Button("Copy snapshot", id="btn-fork-copy", variant="success")
|
|
yield Button("Share snapshot", id="btn-fork-share", variant="warning")
|
|
with Vertical(id="exit-bar"):
|
|
yield Static("Keep this session alive for later?", id="exit-label")
|
|
with Horizontal(id="exit-buttons"):
|
|
yield Button("Keep Alive", id="btn-keep", variant="success")
|
|
yield Button("Destroy", id="btn-destroy", variant="error")
|
|
yield OptionList(id="slash-menu")
|
|
yield Input(placeholder="Connecting to Temporal...", disabled=True, id="chat-input")
|
|
with Horizontal(id="status-bar"):
|
|
yield Static("Connecting...", id="liveness")
|
|
yield Static("", id="activity")
|
|
yield Footer()
|
|
|
|
async def on_mount(self) -> None:
|
|
# Start in session-picker mode: hide chat UI
|
|
self.query_one("#chat").display = False
|
|
self.query_one("#chat-input", Input).display = False
|
|
self.query_one("#approval-bar").display = False
|
|
self.query_one("#fork-bar").display = False
|
|
self.query_one("#exit-bar").display = False
|
|
self.query_one("#snapshot-picker").display = False
|
|
self.query_one("#backend-picker").display = False
|
|
self.query_one("#workspace-picker").display = False
|
|
self._init_temporal()
|
|
|
|
# -- Phase 1: Connect to Temporal and populate session picker -----------
|
|
|
|
@work
|
|
async def _init_temporal(self) -> None:
|
|
tree = self.query_one("#session-picker", Tree)
|
|
|
|
try:
|
|
plugin = OpenAIAgentsPlugin()
|
|
self._temporal_client = await Client.connect(
|
|
"localhost:7233",
|
|
plugins=[plugin],
|
|
)
|
|
except Exception as e:
|
|
self._set_liveness(f"Connection failed: {e}")
|
|
return
|
|
|
|
# Ensure the session manager singleton is running
|
|
try:
|
|
self._manager_handle = await self._temporal_client.start_workflow(
|
|
SessionManagerWorkflow.run,
|
|
id=MANAGER_WORKFLOW_ID,
|
|
task_queue=self._task_queue,
|
|
)
|
|
except WorkflowAlreadyStartedError:
|
|
self._manager_handle = self._temporal_client.get_workflow_handle(MANAGER_WORKFLOW_ID)
|
|
|
|
# Query existing sessions, backfill live snapshot IDs, and build the tree
|
|
sessions = await self._manager_handle.query(SessionManagerWorkflow.list_sessions)
|
|
await self._backfill_snapshot_ids(sessions)
|
|
self._populate_session_tree(tree, sessions)
|
|
|
|
self._set_liveness("Select a session")
|
|
tree.root.expand_all()
|
|
tree.focus()
|
|
|
|
# Distinct background colors for snapshot badges — chosen for
|
|
# readability on both light and dark terminal themes.
|
|
_SNAPSHOT_COLORS = [
|
|
("on dark_green", "bold white"),
|
|
("on dark_blue", "bold white"),
|
|
("on dark_magenta", "bold white"),
|
|
("on dark_cyan", "bold white"),
|
|
("on dark_red", "bold white"),
|
|
("on yellow", "bold black"),
|
|
("on dodger_blue2", "bold white"),
|
|
("on deep_pink4", "bold white"),
|
|
("on orange3", "bold black"),
|
|
("on chartreuse4", "bold white"),
|
|
]
|
|
|
|
def _populate_session_tree(self, tree: Tree, sessions: list) -> None:
|
|
"""Build a nested tree from sessions with parent/child relationships."""
|
|
tree.root.remove_children()
|
|
self._cached_sessions = list(sessions)
|
|
|
|
# Index sessions by workflow_id and group children by parent
|
|
by_id: dict[str, object] = {}
|
|
children_of: dict[str | None, list] = {None: []}
|
|
for s in sessions:
|
|
by_id[s.workflow_id] = s
|
|
parent = s.parent_workflow_id
|
|
# If the parent was destroyed, treat this as a root session
|
|
if parent and parent not in {si.workflow_id for si in sessions}:
|
|
parent = None
|
|
children_of.setdefault(parent, [])
|
|
children_of[parent].append(s)
|
|
|
|
# Build a stable color mapping for unique snapshot IDs
|
|
unique_snap_ids: list[str] = []
|
|
seen: set[str] = set()
|
|
for s in sessions:
|
|
if s.snapshot_id and s.snapshot_id not in seen:
|
|
unique_snap_ids.append(s.snapshot_id)
|
|
seen.add(s.snapshot_id)
|
|
snap_color_map: dict[str, tuple[str, str]] = {}
|
|
for i, sid in enumerate(unique_snap_ids):
|
|
snap_color_map[sid] = self._SNAPSHOT_COLORS[i % len(self._SNAPSHOT_COLORS)]
|
|
|
|
def _format_label(s: SessionInfo) -> Text:
|
|
utc_time = s.created_at.replace(tzinfo=timezone.utc)
|
|
created = utc_time.astimezone().strftime("%Y-%m-%d %I:%M %p")
|
|
|
|
label = Text()
|
|
label.append(f"{s.title} ")
|
|
label.append(f"({created})", style="dim")
|
|
|
|
if s.backend:
|
|
label.append(f" [{s.backend.type}]", style="bold dim")
|
|
|
|
if s.snapshot_id:
|
|
short = s.snapshot_id[:8]
|
|
bg, fg = snap_color_map[s.snapshot_id]
|
|
label.append(" ")
|
|
label.append(f" {short} ", style=f"{fg} {bg}")
|
|
|
|
return label
|
|
|
|
def _add_children(parent_node, parent_id: str | None) -> None:
|
|
for s in children_of.get(parent_id, []):
|
|
label = _format_label(s)
|
|
if children_of.get(s.workflow_id):
|
|
branch = parent_node.add(label, data=s.workflow_id)
|
|
_add_children(branch, s.workflow_id)
|
|
else:
|
|
parent_node.add_leaf(label, data=s.workflow_id)
|
|
|
|
_add_children(tree.root, None)
|
|
tree.root.add_leaf("+ New Session", data=NEW_SESSION_ID)
|
|
if sessions:
|
|
tree.root.add_leaf("+ New from snapshot...", data=NEW_FROM_SNAPSHOT_ID)
|
|
|
|
# -- Session selection --------------------------------------------------
|
|
|
|
async def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
node_data = event.node.data
|
|
if node_data is None:
|
|
return
|
|
|
|
tree_id = event.node.tree.id
|
|
|
|
# Handle snapshot picker selection (choosing source for "new from snapshot")
|
|
if tree_id == "snapshot-picker":
|
|
self.query_one("#snapshot-picker").display = False
|
|
self._create_session_from_snapshot(str(node_data))
|
|
return
|
|
|
|
# Handle main session picker
|
|
self.query_one("#session-picker").display = False
|
|
|
|
if node_data == NEW_SESSION_ID:
|
|
self._pending_backend_action = "new_session"
|
|
self._show_backend_picker()
|
|
return
|
|
elif node_data == NEW_FROM_SNAPSHOT_ID:
|
|
self._show_snapshot_source_picker()
|
|
else:
|
|
self._resume_session(str(node_data))
|
|
|
|
def _show_backend_picker(self) -> None:
|
|
"""Show the backend selection buttons."""
|
|
self.query_one("#backend-picker").display = True
|
|
self._set_liveness("Choose a sandbox backend")
|
|
|
|
def _on_backend_chosen(self, backend: BackendConfig) -> None:
|
|
"""Dispatch after the backend picker completes."""
|
|
if self._pending_backend_action == "switch":
|
|
self._switch_backend(backend)
|
|
elif self._pending_backend_action == "fork":
|
|
self._fork_session(self._pending_fork_title, backend)
|
|
self._pending_fork_title = None
|
|
else:
|
|
self._create_new_session(backend=backend)
|
|
|
|
def _show_snapshot_source_picker(self) -> None:
|
|
"""Show a sub-tree of sessions to pick a snapshot source from."""
|
|
tree = self.query_one("#snapshot-picker", Tree)
|
|
tree.root.remove_children()
|
|
for s in self._cached_sessions:
|
|
utc_time = s.created_at.replace(tzinfo=timezone.utc)
|
|
created = utc_time.astimezone().strftime("%Y-%m-%d %I:%M %p")
|
|
tree.root.add_leaf(f"{s.title} ({created})", data=s.workflow_id)
|
|
tree.root.expand_all()
|
|
tree.display = True
|
|
self._set_liveness("Pick a session to start from")
|
|
tree.focus()
|
|
|
|
@work
|
|
async def _create_new_session(
|
|
self,
|
|
backend: BackendConfig | None = None,
|
|
) -> None:
|
|
if backend is None:
|
|
backend = DaytonaBackendConfig()
|
|
self.query_one("#chat").display = True
|
|
self._set_liveness("Creating session...")
|
|
self._chat_write(Text(f"Starting new {backend.type} session...\n", style="yellow"))
|
|
|
|
assert self._manager_handle is not None
|
|
assert self._temporal_client is not None
|
|
try:
|
|
workflow_id: str = await self._manager_handle.execute_update(
|
|
SessionManagerWorkflow.create_session,
|
|
CreateSessionRequest(cwd=self._cwd, backend=backend),
|
|
)
|
|
except Exception as e:
|
|
self._chat_write(Text(f"Failed to create session: {e}", style="bold red"))
|
|
self._set_liveness("Error")
|
|
return
|
|
|
|
self._current_workflow_id = workflow_id
|
|
self._current_backend = backend.type
|
|
self._handle = self._temporal_client.get_workflow_handle(workflow_id)
|
|
self._current_turn_id = 0
|
|
self._set_session_title(f"Session {workflow_id[-8:]}")
|
|
|
|
self._chat_write(Text(f"Session started: {workflow_id}\n", style="green"))
|
|
self._switch_to_chat()
|
|
|
|
@work
|
|
async def _create_session_from_snapshot(self, source_workflow_id: str) -> None:
|
|
self.query_one("#chat").display = True
|
|
self._set_liveness("Creating session from snapshot...")
|
|
self._chat_write(Text("Creating session from existing snapshot...\n", style="yellow"))
|
|
|
|
assert self._manager_handle is not None
|
|
assert self._temporal_client is not None
|
|
try:
|
|
workflow_id: str = await self._manager_handle.execute_update(
|
|
SessionManagerWorkflow.fork_session,
|
|
ForkSessionRequest(source_workflow_id=source_workflow_id),
|
|
)
|
|
except Exception as e:
|
|
self._chat_write(Text(f"Failed to create session: {e}", style="bold red"))
|
|
self._set_liveness("Error")
|
|
return
|
|
|
|
self._current_workflow_id = workflow_id
|
|
self._handle = self._temporal_client.get_workflow_handle(workflow_id)
|
|
self._current_turn_id = 0
|
|
self._set_session_title(f"Session {workflow_id[-8:]}")
|
|
|
|
self._chat_write(Text(f"Session started from snapshot: {workflow_id}\n", style="green"))
|
|
self._switch_to_chat()
|
|
|
|
@work
|
|
async def _resume_session(self, workflow_id: str) -> None:
|
|
self.query_one("#chat").display = True
|
|
self._set_liveness("Resuming session...")
|
|
|
|
assert self._temporal_client is not None
|
|
self._current_workflow_id = workflow_id
|
|
self._handle = self._temporal_client.get_workflow_handle(workflow_id)
|
|
|
|
# Sync turn_id so we don't mistake prior "complete" as a new response
|
|
try:
|
|
state = await self._handle.query(self._workflow_cls.get_turn_state)
|
|
self._current_turn_id = state.turn_id
|
|
except Exception:
|
|
self._current_turn_id = 0
|
|
|
|
# Replay conversation history from the workflow
|
|
try:
|
|
history: list[dict] = await self._handle.query(self._workflow_cls.get_history)
|
|
self._render_history(history)
|
|
except Exception as e:
|
|
self._chat_write(Text(f"Could not load history: {e}", style="yellow"))
|
|
|
|
# Look up the session title and backend from the manager
|
|
assert self._manager_handle is not None
|
|
try:
|
|
sessions = await self._manager_handle.query(SessionManagerWorkflow.list_sessions)
|
|
for s in sessions:
|
|
if s.workflow_id == workflow_id:
|
|
self._set_session_title(s.title)
|
|
self._current_backend = s.backend.type
|
|
break
|
|
except Exception:
|
|
self._set_session_title(workflow_id[-8:])
|
|
|
|
self._chat_write(Text(f"Resumed session: {workflow_id}\n", style="green"))
|
|
self._switch_to_chat()
|
|
|
|
def _set_session_title(self, title: str) -> None:
|
|
"""Update the header to show the active session title."""
|
|
self.sub_title = title
|
|
|
|
def _switch_to_chat(self) -> None:
|
|
"""Transition from session picker to chat mode."""
|
|
input_w = self.query_one("#chat-input", Input)
|
|
input_w.display = True
|
|
input_w.placeholder = "Type a message, or / for commands..."
|
|
input_w.disabled = False
|
|
input_w.focus()
|
|
self._set_liveness(Text(f"● Active [{self._current_backend}]", style="green"))
|
|
self._set_activity()
|
|
self._poll_timer = self.set_interval(3, self._poll_liveness)
|
|
|
|
def _render_history(self, history: list[dict]) -> None:
|
|
"""Replay conversation history returned by the workflow query."""
|
|
for entry in history:
|
|
if entry.get("role") == "user":
|
|
self._chat_write(Text(f"> {entry['content']}", style="bold cyan"))
|
|
elif entry.get("role") == "assistant":
|
|
self._chat_write(Markdown(entry["content"]))
|
|
if history:
|
|
self._chat_write(Text("--- session restored ---\n", style="dim"))
|
|
|
|
# -- Liveness polling ---------------------------------------------------
|
|
|
|
@work(exclusive=True, group="liveness")
|
|
async def _poll_liveness(self) -> None:
|
|
"""Query the workflow's paused state and update the status bar."""
|
|
if self._handle is None:
|
|
return
|
|
try:
|
|
paused = await self._handle.query(self._workflow_cls.is_paused)
|
|
except Exception:
|
|
return
|
|
was_paused = self._last_paused
|
|
self._last_paused = paused
|
|
if paused:
|
|
self._set_liveness(Text(f"● Paused [{self._current_backend}]", style="yellow"))
|
|
else:
|
|
self._set_liveness(Text(f"● Active [{self._current_backend}]", style="green"))
|
|
# Session just came back — promote "Resuming..." to "Thinking..."
|
|
if was_paused:
|
|
self._set_activity(Text("Thinking...", style="cyan"))
|
|
|
|
# -- Slash-command autocomplete -------------------------------------------
|
|
|
|
def _accept_slash_highlighted(self) -> None:
|
|
"""Tab-accept: insert highlighted command, dismiss menu."""
|
|
menu = self.query_one("#slash-menu", OptionList)
|
|
input_w = self.query_one("#chat-input", Input)
|
|
if menu.highlighted is None:
|
|
return
|
|
option = menu.get_option_at_index(menu.highlighted)
|
|
cmd = option.id
|
|
menu.display = False
|
|
self._slash_menu_open = False
|
|
input_w.value = cmd + " " if cmd != "/done" else "/done"
|
|
input_w.focus()
|
|
self.set_timer(0.05, lambda: setattr(input_w, "cursor_position", len(input_w.value)))
|
|
|
|
_slash_menu_open: bool = False
|
|
|
|
async def on_input_changed(self, event: Input.Changed) -> None:
|
|
if event.input.id != "chat-input":
|
|
return
|
|
menu = self.query_one("#slash-menu", OptionList)
|
|
val = event.value
|
|
if not val.startswith("/") or " " in val:
|
|
menu.display = False
|
|
self._slash_menu_open = False
|
|
return
|
|
# Filter commands matching the typed prefix
|
|
prefix = val.lower()
|
|
matches = [(cmd, desc) for cmd, desc in SLASH_COMMANDS if cmd.split()[0].startswith(prefix)]
|
|
menu.clear_options()
|
|
for cmd, desc in matches:
|
|
menu.add_option(Option(f"{cmd} — {desc}", id=cmd.split()[0]))
|
|
menu.display = bool(matches)
|
|
self._slash_menu_open = bool(matches)
|
|
if matches:
|
|
menu.highlighted = 0
|
|
|
|
async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
self._accept_slash_highlighted()
|
|
|
|
async def on_key(self, event) -> None:
|
|
if not self._slash_menu_open:
|
|
return
|
|
menu = self.query_one("#slash-menu", OptionList)
|
|
if event.key == "up":
|
|
if menu.highlighted is not None and menu.highlighted > 0:
|
|
menu.highlighted -= 1
|
|
event.prevent_default()
|
|
event.stop()
|
|
elif event.key == "down":
|
|
if menu.highlighted is not None:
|
|
menu.highlighted += 1
|
|
event.prevent_default()
|
|
event.stop()
|
|
elif event.key == "tab":
|
|
self._accept_slash_highlighted()
|
|
event.prevent_default()
|
|
event.stop()
|
|
elif event.key == "escape":
|
|
menu.display = False
|
|
self._slash_menu_open = False
|
|
event.prevent_default()
|
|
event.stop()
|
|
|
|
# -- Phase 2: Chat ------------------------------------------------------
|
|
|
|
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
if event.input.id == "workspace-input":
|
|
# Treat Enter on workspace input as clicking Accept
|
|
self.query_one("#workspace-picker").display = False
|
|
raw = event.value.strip()
|
|
workspace_root = Path(raw) if raw else Path(self._cwd) / "workspace"
|
|
self._on_backend_chosen(LocalBackendConfig(workspace_root=workspace_root))
|
|
return
|
|
|
|
self.query_one("#slash-menu", OptionList).display = False
|
|
self._slash_menu_open = False
|
|
|
|
message = event.value.strip()
|
|
if not message:
|
|
return
|
|
|
|
input_w = self.query_one("#chat-input", Input)
|
|
input_w.value = ""
|
|
|
|
# Meta-command: /title <name>
|
|
if message.startswith("/title "):
|
|
new_title = message[len("/title ") :].strip()
|
|
if new_title:
|
|
self._rename_session(new_title)
|
|
return
|
|
|
|
# Meta-command: /fork [optional title] — pick backend then fork
|
|
if message == "/fork" or message.startswith("/fork "):
|
|
self._pending_fork_title = message[len("/fork") :].strip() or None
|
|
self._pending_backend_action = "fork"
|
|
self._show_backend_picker()
|
|
return
|
|
|
|
# Meta-command: /switch — interactively switch sandbox backend
|
|
if message == "/switch":
|
|
self._pending_backend_action = "switch"
|
|
self._show_backend_picker()
|
|
return
|
|
|
|
# Exit flow
|
|
if message.lower() == "/done":
|
|
self._show_exit_prompt()
|
|
return
|
|
|
|
self._chat_write(Text(f"> {message}", style="bold cyan"))
|
|
input_w.disabled = True
|
|
if self._last_paused:
|
|
self._set_activity(Text("Resuming...", style="cyan"))
|
|
else:
|
|
self._set_activity(Text("Thinking...", style="cyan"))
|
|
self._send_message(message)
|
|
|
|
@work
|
|
async def _rename_session(self, new_title: str) -> None:
|
|
assert self._manager_handle is not None
|
|
assert self._current_workflow_id is not None
|
|
try:
|
|
await self._manager_handle.signal(
|
|
SessionManagerWorkflow.rename_session,
|
|
RenameRequest(workflow_id=self._current_workflow_id, title=new_title),
|
|
)
|
|
self._set_session_title(new_title)
|
|
self._chat_write(Text(f"Session renamed to: {new_title}", style="green"))
|
|
except Exception as e:
|
|
self._chat_write(Text(f"Rename failed: {e}", style="bold red"))
|
|
|
|
@work
|
|
async def _fork_session(
|
|
self,
|
|
title: str | None,
|
|
backend: BackendConfig | None = None,
|
|
) -> None:
|
|
input_w = self.query_one("#chat-input", Input)
|
|
|
|
assert self._manager_handle is not None
|
|
assert self._current_workflow_id is not None
|
|
|
|
input_w.disabled = True
|
|
self._set_activity(Text("Forking...", style="cyan"))
|
|
self._chat_write(Text("\nForking session...", style="yellow"))
|
|
|
|
try:
|
|
new_workflow_id: str = await self._manager_handle.execute_update(
|
|
SessionManagerWorkflow.fork_session,
|
|
ForkSessionRequest(
|
|
source_workflow_id=self._current_workflow_id,
|
|
title=title,
|
|
target_backend=backend,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
self._chat_write(Text(f"Fork failed: {e}", style="bold red"))
|
|
self._set_activity(Text("Error", style="red"))
|
|
input_w.disabled = False
|
|
input_w.focus()
|
|
return
|
|
|
|
# Switch to the forked session
|
|
self._current_workflow_id = new_workflow_id
|
|
if backend is not None:
|
|
self._current_backend = backend.type
|
|
self._handle = self._temporal_client.get_workflow_handle(new_workflow_id)
|
|
self._current_turn_id = 0
|
|
|
|
# Resolve the title that was assigned
|
|
fork_title = title or new_workflow_id[-8:]
|
|
try:
|
|
sessions = await self._manager_handle.query(SessionManagerWorkflow.list_sessions)
|
|
for s in sessions:
|
|
if s.workflow_id == new_workflow_id:
|
|
fork_title = s.title
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
self._set_session_title(fork_title)
|
|
self._chat_write(Text(f"Forked! Now in: {fork_title} ({new_workflow_id})", style="green"))
|
|
self._set_liveness(Text(f"● Active [{self._current_backend}]", style="green"))
|
|
self._set_activity()
|
|
input_w.disabled = False
|
|
input_w.focus()
|
|
|
|
@work
|
|
async def _switch_backend(self, backend: BackendConfig) -> None:
|
|
input_w = self.query_one("#chat-input", Input)
|
|
|
|
assert self._manager_handle is not None
|
|
assert self._current_workflow_id is not None
|
|
|
|
input_w.disabled = True
|
|
self._set_activity(Text("Switching backend...", style="cyan"))
|
|
self._chat_write(Text(f"\nSwitching to {backend.type}...", style="yellow"))
|
|
|
|
try:
|
|
await self._manager_handle.execute_update(
|
|
SessionManagerWorkflow.switch_backend,
|
|
SwitchBackendRequest(
|
|
source_workflow_id=self._current_workflow_id,
|
|
target_backend=backend,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
self._chat_write(Text(f"Switch failed: {e}", style="bold red"))
|
|
self._set_activity(Text("Error", style="red"))
|
|
input_w.disabled = False
|
|
input_w.focus()
|
|
return
|
|
|
|
# Same workflow, just a different backend for subsequent turns
|
|
self._current_backend = backend.type
|
|
self._chat_write(Text(f"Switched to {backend.type}!", style="green"))
|
|
self._set_liveness(Text(f"● Active [{self._current_backend}]", style="green"))
|
|
self._set_activity()
|
|
input_w.disabled = False
|
|
input_w.focus()
|
|
|
|
@work
|
|
async def _send_message(self, message: str) -> None:
|
|
"""Signal the workflow with the user message then poll get_turn_state
|
|
until the turn is complete or needs approval. No concurrent timers —
|
|
this single worker owns the entire interaction loop."""
|
|
input_w = self.query_one("#chat-input", Input)
|
|
assert self._handle is not None
|
|
|
|
# Signal is fire-and-forget — returns immediately
|
|
try:
|
|
await self._handle.signal(self._workflow_cls.send_message, message)
|
|
except Exception as e:
|
|
self._chat_write(Text(f"Error sending message: {e}", style="bold red"))
|
|
self._set_activity(Text("Error — try again", style="red"))
|
|
input_w.disabled = False
|
|
input_w.focus()
|
|
return
|
|
|
|
# Poll until the workflow has started and finished this turn.
|
|
# We track turn_id so we don't mistake a stale "complete" from a
|
|
# previous turn as the response to this message.
|
|
while True:
|
|
await asyncio.sleep(1)
|
|
try:
|
|
state: TurnState = await self._handle.query(self._workflow_cls.get_turn_state)
|
|
except Exception as e:
|
|
self._set_activity(Text(f"Poll error: {e}", style="red"))
|
|
continue
|
|
|
|
# Render tool calls as they appear / update
|
|
if state.tool_calls:
|
|
await self._render_live_tool_calls(state)
|
|
|
|
# Wait until the workflow has actually started a new turn
|
|
if state.turn_id <= self._current_turn_id:
|
|
self._set_activity(Text("Waiting...", style="dim"))
|
|
continue
|
|
|
|
if state.status == "thinking":
|
|
self._set_activity(Text("Thinking...", style="cyan"))
|
|
|
|
elif state.status == "awaiting_approval":
|
|
# Don't update _current_turn_id here — the approval
|
|
# continuation is the same turn, so the turn_id check
|
|
# must still pass when we resume polling after "yes"/"no".
|
|
tool_desc = state.approval_request.description if state.approval_request else ""
|
|
self._chat_write(Text(f"\n[approval needed] {tool_desc}", style="yellow"))
|
|
self._set_activity(Text("Approval required", style="yellow"))
|
|
self.query_one("#approval-label", Static).update(Text(tool_desc))
|
|
input_w.display = False
|
|
self.query_one("#approval-bar").display = True
|
|
break
|
|
|
|
elif state.status == "complete":
|
|
self._current_turn_id = state.turn_id
|
|
if state.response_text:
|
|
self._chat_write(Markdown(state.response_text))
|
|
self._set_activity()
|
|
input_w.disabled = False
|
|
input_w.focus()
|
|
break
|
|
|
|
# -- Approval flow ------------------------------------------------------
|
|
|
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
btn = event.button.id
|
|
|
|
# Backend picker buttons
|
|
if btn == "btn-backend-daytona":
|
|
self.query_one("#backend-picker").display = False
|
|
self._on_backend_chosen(DaytonaBackendConfig())
|
|
return
|
|
if btn == "btn-backend-docker":
|
|
self.query_one("#backend-picker").display = False
|
|
self._on_backend_chosen(DockerBackendConfig())
|
|
return
|
|
if btn == "btn-backend-e2b":
|
|
self.query_one("#backend-picker").display = False
|
|
self._on_backend_chosen(E2BBackendConfig())
|
|
return
|
|
if btn == "btn-backend-local":
|
|
self.query_one("#backend-picker").display = False
|
|
# Show workspace root picker with default = cwd/workspace
|
|
default_root = str(Path(self._cwd) / "workspace")
|
|
ws_input = self.query_one("#workspace-input", Input)
|
|
ws_input.value = default_root
|
|
self.query_one("#workspace-picker").display = True
|
|
ws_input.focus()
|
|
self._set_liveness("Choose workspace root")
|
|
return
|
|
|
|
# Workspace picker buttons
|
|
if btn == "btn-workspace-accept":
|
|
self.query_one("#workspace-picker").display = False
|
|
raw = self.query_one("#workspace-input", Input).value.strip()
|
|
workspace_root = Path(raw) if raw else Path(self._cwd) / "workspace"
|
|
self._on_backend_chosen(LocalBackendConfig(workspace_root=workspace_root))
|
|
return
|
|
if btn == "btn-workspace-cancel":
|
|
self.query_one("#workspace-picker").display = False
|
|
self._show_backend_picker()
|
|
return
|
|
|
|
# Approval buttons
|
|
if btn in ("btn-approve", "btn-deny"):
|
|
approved = btn == "btn-approve"
|
|
self._chat_write(
|
|
Text(
|
|
f" -> {'approved' if approved else 'denied'}",
|
|
style="green" if approved else "red",
|
|
)
|
|
)
|
|
self.query_one("#approval-bar").display = False
|
|
self.query_one("#chat-input", Input).display = True
|
|
self.query_one("#chat-input", Input).disabled = True
|
|
self._set_activity(Text("Thinking...", style="cyan"))
|
|
self._send_message("yes" if approved else "no")
|
|
return
|
|
|
|
# Fork buttons (kept for UI compatibility, both trigger the same fork)
|
|
if btn in ("btn-fork-copy", "btn-fork-share"):
|
|
self.query_one("#fork-bar").display = False
|
|
self.query_one("#chat-input", Input).display = True
|
|
self._fork_session(self._pending_fork_title)
|
|
self._pending_fork_title = None
|
|
return
|
|
|
|
# Exit buttons
|
|
if btn == "btn-keep":
|
|
self._on_exit_choice(keep_alive=True)
|
|
return
|
|
if btn == "btn-destroy":
|
|
self._on_exit_choice(keep_alive=False)
|
|
return
|
|
|
|
# -- Phase 3: Exit prompt -----------------------------------------------
|
|
|
|
def _show_exit_prompt(self) -> None:
|
|
"""Show the keep-alive / destroy choice."""
|
|
self.query_one("#chat-input", Input).display = False
|
|
self.query_one("#exit-bar").display = True
|
|
self._set_activity("Choose an exit option")
|
|
|
|
@work
|
|
async def _on_exit_choice(self, keep_alive: bool) -> None:
|
|
self.query_one("#exit-bar").display = False
|
|
|
|
if keep_alive:
|
|
# Pause the workflow so the sandbox state is persisted.
|
|
if self._handle is not None:
|
|
self._set_activity(Text("Saving session...", style="cyan"))
|
|
try:
|
|
await self._handle.execute_update(self._workflow_cls.pause)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
assert self._manager_handle is not None
|
|
assert self._current_workflow_id is not None
|
|
try:
|
|
await self._manager_handle.execute_update(
|
|
SessionManagerWorkflow.destroy_session,
|
|
self._current_workflow_id,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
self._return_to_session_picker()
|
|
|
|
def _return_to_session_picker(self) -> None:
|
|
"""Reset chat state and show the session picker again."""
|
|
if self._poll_timer is not None:
|
|
self._poll_timer.stop()
|
|
self._poll_timer = None
|
|
self._handle = None
|
|
self._current_workflow_id = None
|
|
|
|
# Hide chat UI
|
|
self._chat_clear()
|
|
self.query_one("#chat").display = False
|
|
self.query_one("#chat-input", Input).display = False
|
|
self.query_one("#approval-bar").display = False
|
|
self.query_one("#fork-bar").display = False
|
|
self.query_one("#exit-bar").display = False
|
|
self.query_one("#snapshot-picker").display = False
|
|
self.query_one("#backend-picker").display = False
|
|
self.query_one("#workspace-picker").display = False
|
|
|
|
# Re-populate and show the session picker
|
|
self.sub_title = "Temporal Workflow"
|
|
self._refresh_session_picker()
|
|
|
|
@work
|
|
async def _refresh_session_picker(self) -> None:
|
|
"""Re-query sessions and show the picker tree."""
|
|
assert self._manager_handle is not None
|
|
tree = self.query_one("#session-picker", Tree)
|
|
sessions = await self._manager_handle.query(SessionManagerWorkflow.list_sessions)
|
|
await self._backfill_snapshot_ids(sessions)
|
|
self._populate_session_tree(tree, sessions)
|
|
tree.root.expand_all()
|
|
tree.display = True
|
|
self._set_liveness("Select a session")
|
|
self._set_activity()
|
|
tree.focus()
|
|
|
|
# -- Graceful quit (Ctrl+C) ---------------------------------------------
|
|
|
|
def action_quit_graceful(self) -> None:
|
|
if self._handle:
|
|
# In a session — show the keep-alive / destroy prompt
|
|
self._show_exit_prompt()
|
|
else:
|
|
# At the session picker — exit the TUI
|
|
self.exit()
|