Skip to content

Latest commit

 

History

History
300 lines (224 loc) · 12 KB

File metadata and controls

300 lines (224 loc) · 12 KB

Terminal Management System & Task Submission GUI

Context

PumaBot's core agent is a Pantograph workflow that carries out user objectives. It needs two things to be useful: (1) the ability to run CLI tools like Claude Code in managed terminal sessions, and (2) a GUI for users to submit tasks. Every capability must be API-accessible so the core agent can use it programmatically. The terminal system also needs push-based notifications for idle detection and command completion.

No terminal infrastructure exists today — no PTY library, no xterm.js, no terminal-related modules.


Architecture

lib/
├── puma_bot/
│   └── terminal/
│       ├── supervisor.ex          # Supervisor: Registry + DynamicSupervisor
│       ├── session.ex             # GenServer per PTY (ExPTY wrapper, idle detection, PubSub)
│       └── api.ex                 # Public API for core agent (create, read, write, list, subscribe)
│
├── puma_bot_web/
│   ├── live/
│   │   ├── terminal_live.ex       # LiveView: tiled/tabbed terminal GUI
│   │   └── task_live.ex           # LiveView: task submission + agent status
│   └── components/
│       └── layouts/
│           └── root.html.heex     # Add xterm.js CDN script tag
│
priv/static/assets/
├── js/app.js                      # Add XTerm + TerminalLayout hooks
└── css/app.css                    # Terminal grid/tab styling

Phase 1: Backend — Terminal Session GenServer

1a. Add ExPTY dependency

File: mix.exs

  • Add {:ex_pty, "~> 0.7"} to deps

1b. Create PumaBot.Terminal.Supervisor

File: lib/puma_bot/terminal/supervisor.ex

Follow the exact pattern from PumaBot.Workflow.Supervisor (workflow/supervisor.ex):

children = [
  {Registry, keys: :unique, name: PumaBot.Terminal.Registry},
  {DynamicSupervisor, name: PumaBot.Terminal.SessionSupervisor, strategy: :one_for_one}
]
Supervisor.init(children, strategy: :one_for_all)

1c. Create PumaBot.Terminal.Session

File: lib/puma_bot/terminal/session.ex

GenServer wrapping ExPTY. Follow the pattern from PumaBot.Workflow.Session (workflow/session.ex) for Registry-based naming, DynamicSupervisor start, and PubSub broadcasting.

State struct:

defstruct [:id, :pty, :command, :args, :cols, :rows, :cwd,
           :scrollback, :last_activity_at, :idle_timer_ref,
           status: :running, idle_timeout_ms: 30_000]

Key behaviors:

  • init/1: Spawn PTY via ExPTY.spawn(command, args, opts). Store self() PID for on_data/on_exit callbacks. Start idle detection timer via Process.send_after(self(), :check_idle, idle_timeout_ms).
  • handle_info({:pty_data, data}): Append to scrollback buffer (capped at configurable max, e.g. 100KB). Broadcast {:terminal_output, id, data} via PubSub to "terminal:#{id}". Reset last_activity_at.
  • handle_info({:pty_exit, exit_code}): Set status: :exited. Broadcast {:terminal_exit, id, exit_code}.
  • handle_info(:check_idle): If System.monotonic_time(:millisecond) - last_activity_at > idle_timeout_ms and status == :running, broadcast {:terminal_idle, id}. Reschedule timer.
  • handle_cast({:write, data}): Call ExPTY.write(pty, data). Reset last_activity_at.
  • handle_cast({:resize, cols, rows}): Call ExPTY.resize(pty, rows, cols). Update state.
  • handle_call(:read_buffer, ...): Return current scrollback buffer contents.
  • terminate/2: Kill the PTY process if still running.

PubSub events (all broadcast to "terminal:#{id}"):

  • {:terminal_output, id, binary_data} — new output chunk
  • {:terminal_idle, id} — no output for idle_timeout_ms
  • {:terminal_exit, id, exit_code} — process exited

1d. Create PumaBot.Terminal.Api

File: lib/puma_bot/terminal/api.ex

Public API module the core agent calls. Thin wrapper over Session GenServer calls:

def create(command, args \\ [], opts \\ [])   # -> {:ok, id} | {:error, reason}
def write(id, data)                            # -> :ok
def read(id)                                   # -> {:ok, buffer} | {:error, :not_found}
def resize(id, cols, rows)                     # -> :ok
def subscribe(id)                              # -> :ok  (PubSub subscribe)
def list()                                     # -> [%{id, command, status, ...}]
def stop(id)                                   # -> :ok
def get_status(id)                             # -> %{id, command, status, cols, rows, ...}

All lookups via {:via, Registry, {PumaBot.Terminal.Registry, id}}.

1e. Wire into supervision tree

File: lib/puma_bot/application.ex

Add PumaBot.Terminal.Supervisor to children list (after PumaBot.Workflow.Supervisor, before PumaBotWeb.Endpoint).


Phase 2: Frontend — xterm.js Integration

2a. Add xterm.js to root layout

File: lib/puma_bot_web/components/layouts/root.html.heex

Add CDN script tags (matching the existing CDN pattern for daisyUI/Tailwind/QRCode):

<!-- Terminal emulator -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
<script defer src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>

2b. Add XTerm LiveView hook

File: priv/static/assets/js/app.js

Add Hooks.XTerm hook:

  • mounted(): Create Terminal instance + FitAddon. Open terminal in the hook element. Call fitAddon.fit(). Listen for terminal_output push events -> term.write(atob(data)). Attach term.onData(data => pushEvent("terminal_input", {id, data})). Attach ResizeObserver -> fitAddon.fit() then pushEvent("terminal_resize", {id, cols, rows}).
  • destroyed(): Dispose terminal instance.

2c. Add terminal grid CSS

File: priv/static/assets/css/app.css

Minimal CSS for terminal grid layout. Use CSS Grid for tiled layout. DaisyUI tabs for tab switching.


Phase 3: LiveView — Terminal Dashboard

3a. Create PumaBotWeb.TerminalLive

File: lib/puma_bot_web/live/terminal_live.ex

State assigns:

%{
  terminals: %{},          # id => %{command, status, tab_name}
  active_terminal: nil,    # id of focused terminal
  layout: :tabs,           # :tabs | :tiled
  new_command: ""           # input field value
}

Key behaviors:

  • mount/3: List existing sessions via Terminal.Api.list(). Subscribe to each. Assign state.
  • handle_event("create_terminal", params): Call Terminal.Api.create(command, args, opts). Subscribe to PubSub. Add to assigns.
  • handle_event("terminal_input", %{"id" => id, "data" => data}): Call Terminal.Api.write(id, data).
  • handle_event("terminal_resize", %{"id" => id, "cols" => c, "rows" => r}): Call Terminal.Api.resize(id, c, r).
  • handle_event("switch_tab", %{"id" => id}): Update active_terminal.
  • handle_event("close_terminal", %{"id" => id}): Call Terminal.Api.stop(id). Remove from assigns.
  • handle_event("toggle_layout", _): Toggle between :tabs and :tiled.
  • handle_info({:terminal_output, id, data}): Push event "terminal_output:#{id}" with base64 data -> XTerm hook renders it.
  • handle_info({:terminal_exit, id, code}): Update terminal status. Show notification.
  • handle_info({:terminal_idle, id}): Show idle indicator badge on tab.

Template structure:

+--------------------------------------------------+
| [+ New Terminal]  [Tabs | Tiled]                 | <- toolbar
+--------------------------------------------------+
| [bash] [claude] [mix test] [x]                   | <- tab bar (when layout=:tabs)
+--------------------------------------------------+
|                                                  |
|   <div id="xterm-{id}" phx-hook="XTerm"          | <- xterm.js container
|        phx-update="ignore"                       |
|        data-terminal-id={id}>                    |
|   </div>                                         |
|                                                  |
+--------------------------------------------------+

For tiled layout: CSS grid with equal columns per terminal (2-col for 2-3 terminals, etc).

3b. Add route

File: lib/puma_bot_web/router.ex

Add inside the existing scope "/", PumaBotWeb block:

live "/terminals", TerminalLive, :index

Phase 4: Task Submission GUI

4a. Create PumaBotWeb.TaskLive

File: lib/puma_bot_web/live/task_live.ex

Simple interface for submitting objectives to the core agent. Since the core Pantograph agent isn't built yet, this creates the UI scaffold and the message pipeline:

State assigns:

%{
  task_input: "",           # textarea value
  tasks: [],                # list of submitted tasks with status
  agent_status: :idle       # :idle | :working | :error
}

Key behaviors:

  • handle_event("submit_task", %{"task" => text}): Broadcast task to PubSub topic "agent:tasks". Add to tasks list with :pending status. (The core agent, when built, subscribes to this topic.)
  • handle_info({:task_update, task_id, status}): Update task status in assigns.

Template: Text area input + submit button + scrollable task history with status badges.

4b. Add route

File: lib/puma_bot_web/router.ex

live "/tasks", TaskLive, :index

Phase 5: Navigation

5a. Update app layout with nav links

File: lib/puma_bot_web/components/layouts.ex

Update the app/1 function's header to include navigation links to all pages:

  • Chats (/)
  • Terminals (/terminals)
  • Tasks (/tasks)

Replace the default Phoenix links (Website, GitHub, Get Started) with PumaBot navigation.


Files Summary

Create (5 files):

  1. lib/puma_bot/terminal/supervisor.ex — Supervisor (Registry + DynamicSupervisor)
  2. lib/puma_bot/terminal/session.ex — GenServer per PTY
  3. lib/puma_bot/terminal/api.ex — Public API for core agent
  4. lib/puma_bot_web/live/terminal_live.ex — Terminal dashboard LiveView
  5. lib/puma_bot_web/live/task_live.ex — Task submission LiveView

Modify (7 files):

  1. mix.exs — Add {:ex_pty, "~> 0.7"}
  2. lib/puma_bot/application.ex — Add PumaBot.Terminal.Supervisor
  3. lib/puma_bot_web/components/layouts/root.html.heex — Add xterm.js CDN
  4. lib/puma_bot_web/components/layouts.ex — Add navigation links
  5. lib/puma_bot_web/router.ex — Add /terminals and /tasks routes
  6. priv/static/assets/js/app.js — Add XTerm hook
  7. priv/static/assets/css/app.css — Add terminal grid styles

Implementation Order

  1. mix.exs — add ex_pty dep, run mix deps.get
  2. terminal/supervisor.ex — supervision tree
  3. terminal/session.ex — GenServer + ExPTY + idle detection
  4. terminal/api.ex — public API
  5. application.ex — wire supervisor
  6. Compile and smoke test backend via IEx
  7. root.html.heex — xterm.js CDN
  8. app.js — XTerm hook
  9. app.css — terminal grid styles
  10. terminal_live.ex — terminal dashboard
  11. router.ex — add routes
  12. task_live.ex — task submission
  13. layouts.ex — navigation
  14. Compile and end-to-end test in browser

Verification

  1. Compile: ./launcher.sh compile passes with zero warnings
  2. Backend smoke test (IEx):
    {:ok, id} = PumaBot.Terminal.Api.create("bash", ["-l"])
    PumaBot.Terminal.Api.subscribe(id)
    PumaBot.Terminal.Api.write(id, "echo hello\n")
    # Should receive {:terminal_output, ^id, data} with "hello" in it
    PumaBot.Terminal.Api.stop(id)
  3. Idle detection: Create session, wait 30s without input, verify {:terminal_idle, id} broadcast received
  4. Browser test: Navigate to /terminals, create a terminal, type commands, see output rendered in xterm.js
  5. Tiled layout: Create 2+ terminals, toggle to tiled view, verify both render and accept input
  6. Task submission: Navigate to /tasks, type an objective, submit, see it in task list
  7. Navigation: All nav links work between Chats, Terminals, Tasks