# pi-textual

Pi extension for the [Textual](https://github.com/textualize/textual) Python TUI
framework.

The extension runs a Textual app headless in a Python subprocess. It drives the
app with pilot actions. It returns the screen text to the agent. It can also
export the screen as an SVG screenshot.

## Requirements

- Node.js 24 or newer.
- Python 3.9 or newer.
- Textual 8.2.x installed for the Python interpreter: `pip install "textual>=8.2,<8.3"`.
  The driver uses a private Textual API (`app.screen._compositor.render_strips()`)
  that is pinned to the 8.2 line. Newer Textual versions report an error with
  installation instructions.

## Start pi with the extension

Start pi with the extension file:

```bash
pi -e ./src/index.ts
```

## Tool: textual_run

The extension registers one tool: `textual_run`.

### Parameters

- `app`: the path to the Python file that defines the app. The file must
  define a module-level `app` instance, or exactly one App subclass. Required
  for one-shot runs and when opening a new session. Omit it when acting on an
  open session.
- `actions`: the pilot actions, in order. Each action is one of:
  - `{ "press": "tab" }`: press a key. A list of keys is allowed, for example
    `{ "press": ["ctrl+c"] }`.
  - `{ "click": "#button" }`: click a widget selector. `click` accepts an
    object with `selector`, `offset`, `times`, and `button`.
  - `{ "double_click": "#row" }`: double-click a widget selector.
  - `{ "hover": "#button" }`: move the pointer over a widget selector.
  - `{ "pause": 0.5 }`: wait in seconds.
  - `{ "resize": [100, 30] }`: resize the terminal.
  - `{ "screenshot": true }`: export the screen as an SVG at this point.
- `size`: the initial terminal size, as [width, height]. The default is [80, 25].
- `screenshot`: set to true to export the final screen as an SVG file. The
  file is written next to the app file. The result contains the file path.
- `tree`: set to true to include the widget tree in the result. The tree
  lists every widget with its type, id, classes, visibility, disabled state,
  and focus state.
- `query`: a list of widget selectors. The result includes a state snapshot
  for the widgets that match: type, id, classes, visibility, focus, and
  widget-specific state such as Input value, Button label, or DataTable row
  count.
- `session`: the name of a persistent session. With `app`, opens a new
  session that keeps the app alive between calls. Without `app`, drives the
  already-open session.
- `close`: set to true with `session` to close that session and stop its app.
- `python`: the Python interpreter. The default is `python3`.
- `timeout`: the execution timeout, in milliseconds. The default is 120000.

### Result

The result contains the screen text. The details contain the app exit value,
the screenshot path, the terminal size, and the interpreter used.

When `actions` is present, the tool streams a screen-text update after each
action. The agent sees the screen change step by step while the run is in
progress.

### One-shot runs

Without `session`, the app starts, the actions run, and the app stops in one
call. Use a one-shot run when a single sequence of actions is enough.

### Sessions

With `session`, the app stays alive in a subprocess between calls. Use a
session for multi-step interaction: open once, act and inspect the screen step
by step, then close.

Open a session:

```json
{
  "app": "counter.py",
  "session": "counter",
  "actions": [{ "press": "tab" }, { "click": "#inc" }],
  "tree": true,
  "query": ["#label", "#inc"]
}
```

Act on the open session (no `app`):

```json
{
  "session": "counter",
  "actions": [{ "click": "#inc" }]
}
```

Close the session:

```json
{
  "session": "counter",
  "close": true
}
```

### Example

An example app:

```python
from textual.app import App, ComposeResult
from textual.widgets import Button, Static


class CounterApp(App):
    def __init__(self):
        super().__init__()
        self.count = 0

    def compose(self) -> ComposeResult:
        yield Static(f"count: {self.count}", id="label")
        yield Button("Increment", id="inc")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        self.count += 1
        self.query_one("#label", Static).update(f"count: {self.count}")
```

Run the app:

```json
{
  "app": "counter.py",
  "screenshot": true,
  "actions": [
    { "press": "tab" },
    { "click": "#inc" },
    { "pause": 0.6 },
    { "click": "#inc" }
  ]
}
```

## Limits

- The app runs headless. It writes nothing to standard output. The driver moves
  the results through JSON documents.
- Sessions are subprocesses that die with pi. There is no session persistence
  across pi restarts; up to 8 sessions can be open at once.
- An idle session closes itself after 15 minutes.
- A Textual button absorbs a second click while the press animation runs. Put a
  pause of at least 0.6 seconds between clicks on the same widget.
- The screenshot format is SVG. There is no PNG export.

## Development

```bash
bun install
bun run check      # lint and format check
bun run fix        # auto-fix
bun run typecheck  # type check
bun run test       # unit and integration tests
bun run verify     # check + typecheck + tests
```

E2E tests need Textual installed. Set `PI_TEXTUAL_PYTHON` to the interpreter
that has it, for example:

```bash
PI_TEXTUAL_PYTHON=./.venv/bin/python bun run verify
```
