# 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 ", "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 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()