Initial patterm project
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/third_party/libghostty-vt/source/
|
||||||
|
/third_party/libghostty-vt/install/
|
||||||
|
*.bytes
|
||||||
|
*.crash
|
||||||
|
spike-report-*.txt
|
||||||
|
/.zig-cache/
|
||||||
|
/bin/
|
||||||
|
/spike
|
||||||
42
Makefile
Normal file
42
Makefile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
ROOT := $(abspath .)
|
||||||
|
VENDOR := $(ROOT)/third_party/libghostty-vt
|
||||||
|
SOURCE := $(VENDOR)/source
|
||||||
|
INSTALL := $(VENDOR)/install
|
||||||
|
COMMIT := $(shell cat $(VENDOR)/COMMIT)
|
||||||
|
|
||||||
|
.PHONY: deps deps-fetch deps-build clean-deps spike patterm test
|
||||||
|
|
||||||
|
# `make deps` fetches and builds libghostty-vt at the pinned commit.
|
||||||
|
# Re-runs are idempotent on success; touch $(VENDOR)/COMMIT to force a rebuild.
|
||||||
|
deps: $(INSTALL)/lib/libghostty-vt.a
|
||||||
|
|
||||||
|
$(SOURCE)/.git/HEAD:
|
||||||
|
@echo ">> cloning ghostty-org/ghostty @ $(COMMIT)"
|
||||||
|
@rm -rf $(SOURCE)
|
||||||
|
@git clone --filter=blob:none https://github.com/ghostty-org/ghostty.git $(SOURCE)
|
||||||
|
@cd $(SOURCE) && git checkout --detach $(COMMIT)
|
||||||
|
|
||||||
|
deps-fetch: $(SOURCE)/.git/HEAD
|
||||||
|
|
||||||
|
$(INSTALL)/lib/libghostty-vt.a: $(SOURCE)/.git/HEAD
|
||||||
|
@command -v zig >/dev/null || { echo "ERROR: zig not on PATH (need >=0.15.2 to build libghostty-vt)"; exit 1; }
|
||||||
|
@echo ">> building libghostty-vt with zig"
|
||||||
|
@cd $(SOURCE) && zig build -Demit-lib-vt --prefix $(INSTALL)
|
||||||
|
@test -f $(INSTALL)/lib/libghostty-vt.a || { echo "ERROR: expected static lib at $(INSTALL)/lib/libghostty-vt.a"; exit 1; }
|
||||||
|
@echo ">> libghostty-vt installed under $(INSTALL)"
|
||||||
|
|
||||||
|
deps-build: $(INSTALL)/lib/libghostty-vt.a
|
||||||
|
|
||||||
|
clean-deps:
|
||||||
|
rm -rf $(SOURCE) $(INSTALL)
|
||||||
|
|
||||||
|
spike: deps
|
||||||
|
go build -o ./bin/spike ./cmd/spike
|
||||||
|
|
||||||
|
patterm: deps
|
||||||
|
go build -o ./bin/patterm ./cmd/patterm
|
||||||
|
|
||||||
|
test: deps
|
||||||
|
go test ./...
|
||||||
542
SPEC.md
Normal file
542
SPEC.md
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
This is a spec for a terminal project I have.
|
||||||
|
|
||||||
|
I think we could probably use libghostty for the terminal emulation as it seems the go ecosystem is quite sparse.
|
||||||
|
|
||||||
|
# patterm — v1 Spec
|
||||||
|
|
||||||
|
*Working title: **patterm**. Used throughout this document.*
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
A terminal-based agent orchestration shell. The user opens patterm in a project directory (e.g. `~/Dev/foo`). patterm presents a multi-tab TUI where each top tab is a **session** — a long-running PTY launched from a user-defined **preset**. Presets come in two flavours: **agent presets** (e.g. claude, codex, opencode — vendor LLM CLIs with patterm's MCP wired up) and **process presets** (e.g. `bun run dev`, `vitest --watch` — raw commands with no MCP). Each session has a sidebar of **children**: more presets spawned by that session, again either agents or processes, all PTY-backed. The right rail also surfaces project-scoped **scratchpads** (markdown files) for human readability.
|
||||||
|
|
||||||
|
An MCP server, in-process, exposes tools that let orchestrator agents spawn and drive children, run processes, set timers, message peers, and read/write scratchpads. The orchestrator is a real LLM CLI driving another LLM CLI as if it were a human user — keystroke injection in, rendered-grid scraping out. The orchestrator fully owns the content of what it sends; patterm only handles the plumbing.
|
||||||
|
|
||||||
|
**Goal:** Let one SOTA agent orchestrate other agents of different types (claude → codex, codex → opencode, …) without subagent APIs, while keeping the whole thing steerable and observable by a human at any moment.
|
||||||
|
|
||||||
|
**Non-goal:** Hosting any LLM. patterm only manages CLIs the user already has installed. patterm also doesn't ship hard-coded knowledge of any specific vendor CLI — agent presets are user-editable JSON; the three common ones (claude, codex, opencode) ship as defaults.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture and lifecycle
|
||||||
|
|
||||||
|
**Single foreground process. No daemon, no detach.**
|
||||||
|
|
||||||
|
The tool is one Go process that owns: the TUI, all PTYs, vt-emulated grids, session state, child state, scratchpad files, and an in-process MCP server. Killing the process kills everything inside it. There is no attach/detach, no project-keyed singleton, no socket-based reattachment.
|
||||||
|
|
||||||
|
**Lifecycle:**
|
||||||
|
|
||||||
|
1. User runs `patterm` in a project directory.
|
||||||
|
2. The process starts the TUI as a **blank canvas** — no sessions, no children, no scratchpad preview. Just the empty frame with the palette hint in the status line. The in-process MCP server initializes (bound to a per-PID unix socket for spawned children — see §10) and scratchpad metadata is loaded from disk, but nothing is rendered until the user opens a preset.
|
||||||
|
3. The user opens the palette (`Ctrl-K`), selects a preset, and the first session/process is launched. Subsequent sessions and children are spawned the same way (or by orchestrators via MCP).
|
||||||
|
4. On exit (Ctrl-D, `:quit`, terminal window close, SIGTERM, SIGHUP): the process sends SIGTERM to every child PTY with a short grace window, then SIGKILL, then exits. Scratchpads on disk are the only thing that survives.
|
||||||
|
|
||||||
|
**Multiple invocations:** Running `patterm` twice in the same project starts two independent processes. They share scratchpad files on disk but nothing else. If this turns out to be a footgun in practice, a per-project lockfile can be added later — out of scope for v1.
|
||||||
|
|
||||||
|
**Implications:** Closing the terminal window (or SSH dropping) ends the session and tears down every child. This is the deliberate trade — no orphan daemons, no socket discovery, no stale-state recovery, no multi-client coordination. The user's terminal window *is* the lifetime boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Project state layout
|
||||||
|
|
||||||
|
Scratchpads (user data) live under `$XDG_DATA_HOME`; presets and config live under `$XDG_CONFIG_HOME`.
|
||||||
|
|
||||||
|
```
|
||||||
|
$XDG_DATA_HOME/patterm/
|
||||||
|
└── projects/
|
||||||
|
└── <project-key>/
|
||||||
|
├── meta.json # project path, last-opened, version
|
||||||
|
└── scratchpads/
|
||||||
|
├── notes.md
|
||||||
|
├── todos.md
|
||||||
|
└── <agent-written>.md
|
||||||
|
|
||||||
|
$XDG_CONFIG_HOME/patterm/
|
||||||
|
├── config.json # global settings (theme, default keymap, etc.)
|
||||||
|
└── presets/
|
||||||
|
├── agents/
|
||||||
|
│ ├── claude.json # ships as default
|
||||||
|
│ ├── codex.json # ships as default
|
||||||
|
│ ├── opencode.json # ships as default
|
||||||
|
│ └── <user-defined>.json
|
||||||
|
└── processes/
|
||||||
|
├── dev.json # e.g. { "name": "bun run dev", "argv": ["bun", "run", "dev"] }
|
||||||
|
├── test.json
|
||||||
|
└── <user-defined>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Both preset directories are scanned at startup; every file found becomes a palette entry ("Spawn agent: claude", "Run process: bun run dev", …). Presets are project-agnostic in v1 — the same set is available in every project. Per-project overrides can be added later.
|
||||||
|
|
||||||
|
Project key = `sha256(realpath(project_dir))[:16]`. Used only as a scratchpad directory name — there is no daemon to look up.
|
||||||
|
|
||||||
|
Internal MCP socket (for spawned children to talk to the running process): `$XDG_RUNTIME_DIR/patterm/<pid>.sock`, falling back to `/tmp/patterm-<pid>.sock` if `XDG_RUNTIME_DIR` is unset. Created on startup, removed on exit. Per-PID, not per-project — it is a private IPC channel, not a discovery point.
|
||||||
|
|
||||||
|
Scratchpads persist across runs. Sessions and child processes do not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UI / Client
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┬──────────────────┐
|
||||||
|
│ [codex-1] [codex-2] [claude-1] + │ Session tree │
|
||||||
|
├────────────────────────────────────────────────────────┤ ────── │
|
||||||
|
│ │ ▶ codex-1 │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ├─ ◉ claude-2 │
|
||||||
|
│ │ ├─ ◉ claude-3 │
|
||||||
|
│ (focused pane's PTY) │ ├─ ◉ claude-4 │
|
||||||
|
│ │ └─ ◉ bun-dev │
|
||||||
|
│ │ │
|
||||||
|
│ │ Scratchpads │
|
||||||
|
│ │ ────── │
|
||||||
|
│ │ todos.md │
|
||||||
|
│ │ notes.md │
|
||||||
|
│ │ api-plan.md │
|
||||||
|
│ │ │
|
||||||
|
│ │ ┌────────────┐ │
|
||||||
|
│ │ │ todos.md │ │
|
||||||
|
│ │ │ preview… │ │
|
||||||
|
│ │ └────────────┘ │
|
||||||
|
├────────────────────────────────────────────────────────┴──────────────────┤
|
||||||
|
│ [orchestrator driving] Ctrl-K command palette │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Top tab bar:** one per top-level session. `+` opens the palette pre-filtered to "Spawn…" entries.
|
||||||
|
- **Main area:** the focused pane's PTY, rendered identically to viewing it in a regular terminal. The focused pane is either the orchestrator (root of the active session's tree) or one of its children, whichever the user last selected from the sidebar.
|
||||||
|
- **Right rail, top half — session tree:** the active session's process hierarchy, drawn as an indented tree with box-drawing connectors (`├─`, `└─`). The orchestrator is the root (`▶`); each child appears one level deeper with a status glyph (`◉` running, `✓` exited cleanly, `✗` errored). Selecting an entry (palette, arrow keys, or click) makes it the focused pane. v1 only has two levels because of the §8 two-level-tree rule, but the renderer should be tree-shaped from day one so a future depth bump doesn't require UI surgery.
|
||||||
|
- **Right rail, bottom half:** scratchpad list and a preview of the selected scratchpad.
|
||||||
|
- **Status line:** input-ownership toast ("orchestrator driving" / "you have control") on the left, palette hint on the right.
|
||||||
|
|
||||||
|
**Empty state:** Until the user spawns their first preset, the top tab bar, main area, and sidebar all sit empty with a centred hint ("Press Ctrl-K to spawn an agent or process"). No "default session" is created.
|
||||||
|
|
||||||
|
**Switching:** Clicking a top tab (or selecting one via the palette) switches the active session — the sidebar tree swaps to that session's hierarchy. Clicking a sidebar entry switches the focused pane within the current session.
|
||||||
|
|
||||||
|
**Command palette (v1 input model):**
|
||||||
|
|
||||||
|
Almost all application functions are driven through a single command palette opened with `Ctrl-K`. The palette is a fuzzy-searchable list of commands, scoped to whatever makes sense for the current focus. Two kinds of entries appear:
|
||||||
|
|
||||||
|
- **Built-in commands** — "Switch to session…", "Focus pane…", "Take input control", "Release control to orchestrator", "Open scratchpad…", "Kill child…", "Quit", etc.
|
||||||
|
- **Preset commands** — one entry per file under `$XDG_CONFIG_HOME/patterm/presets/`. Agent presets surface as "Spawn agent: codex" / "Spawn agent: claude" / …; process presets surface as "Run process: bun run dev" / "Run process: vitest" / …. The label comes from the preset's `name` field; the action is "launch this preset into a new pane."
|
||||||
|
|
||||||
|
Selecting a preset either launches it immediately (no required args) or opens a sub-palette for optional args — namely an **initial prompt** (agent presets only), which patterm injects into the spawned PTY's input after the agent is ready (§8). The orchestrator equivalent of this — `spawn_agent` / `spawn_process` MCP tools — uses the exact same machinery: pick a preset by name, optionally supply an initial prompt, patterm handles the rest.
|
||||||
|
|
||||||
|
Rationale: the keybinding surface for sessions + children + scratchpads + control transfer + spawning gets large fast. A palette lets us ship the full feature set without committing to a key map yet, and gives the user a discoverable index of every action. Dedicated keybindings can be layered on top later for the few actions a user does often enough to memorize — they should be configured by binding to palette command IDs, not by re-implementing the action.
|
||||||
|
|
||||||
|
Only two keybindings are reserved at the application level in v1:
|
||||||
|
|
||||||
|
| Action | Binding |
|
||||||
|
|---|---|
|
||||||
|
| Open command palette | `Ctrl-K` |
|
||||||
|
| Pass-through prefix (everything else after this goes to the focused PTY untouched, e.g. for nested tmux/Ctrl-K-using TUIs) | `Ctrl-K Ctrl-K` |
|
||||||
|
|
||||||
|
Everything else — session switching, child cycling, control transfer, quitting — lives in the palette for v1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. PTY layer
|
||||||
|
|
||||||
|
One PTY per session orchestrator and one per child. For each PTY the tool maintains:
|
||||||
|
|
||||||
|
- The underlying process (pid, status, exit code on death).
|
||||||
|
- A raw byte ring buffer (default 1 MiB) for stream-mode reads.
|
||||||
|
- A vt-emulated character grid representing current visible state.
|
||||||
|
- Alt-screen flag (whether the process is in alternate-buffer mode, i.e. a TUI).
|
||||||
|
- Last-write timestamp (used for the idle heuristic).
|
||||||
|
|
||||||
|
**Terminal emulator:** Go has limited options. Start with `vt10x` or a maintained fork. Budget real time — this is the load-bearing component for grid mode `read_output`. The emulator must handle: SGR colours (then strip them on read), cursor movement, alt-screen entry/exit, scroll regions, basic mouse passthrough where needed.
|
||||||
|
|
||||||
|
**Resize:** On startup and on SIGWINCH, the tool reads its own terminal dimensions, computes per-pane winsize (accounting for tab bar, sidebar, status line), and `ioctl(TIOCSWINSZ)` each PTY. Children get SIGWINCH automatically. One process, one viewport — no multi-client resize negotiation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Input ownership
|
||||||
|
|
||||||
|
Each pane has an owner flag: `user` or `orchestrator`. A toast / status-line glyph reflects current owner.
|
||||||
|
|
||||||
|
- When the orchestrator spawns a child, that child defaults to orchestrator-owned.
|
||||||
|
- When the user focuses a pane and presses any key, ownership flips to `user`. The orchestrator can still write — bytes interleave. A warning toast appears: "Orchestrator is also driving this pane."
|
||||||
|
- The user explicitly returns ownership with the release key.
|
||||||
|
|
||||||
|
No locking. The user's call if they collide. The visual indicator is the only protection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. MCP tool surface
|
||||||
|
|
||||||
|
The tool embeds an MCP server in-process. Each spawned agent gets an MCP config injected at spawn time (see §10) pointing at a stdio proxy subcommand of the same binary, which forwards JSON-RPC over the per-PID unix socket to the running process. Tool calls carry an implicit caller identity (which session / which child) derived from the connection.
|
||||||
|
|
||||||
|
### Tools available to orchestrators only
|
||||||
|
|
||||||
|
#### `spawn_agent`
|
||||||
|
- **Args:** `preset` (string — name of an agent preset under `$XDG_CONFIG_HOME/patterm/presets/agents/`), `initial_prompt` (string), `name?` (display name, defaults to `<preset>-<n>`)
|
||||||
|
- **Behaviour:** Launches the agent preset in a new PTY as a child of the calling session. Wires MCP per the preset's injection strategy (§10). Waits for the preset's ready signal (default: 1s idle). Then types `initial_prompt` into the TUI input box and submits. patterm does not inject any other text — the caller's `initial_prompt` is the agent's first turn. If the caller wants the agent to know about the message-tag conventions (§8), tool availability, or its orchestrator role, the caller must say so in `initial_prompt`.
|
||||||
|
- **Returns:** `child_id`.
|
||||||
|
- **Error:** Returns an error if `preset` isn't a known agent preset. patterm has no built-in knowledge of vendor CLIs — everything is preset-driven.
|
||||||
|
|
||||||
|
#### `send_message_to`
|
||||||
|
- **Args:** `target` (child_id), `message` (string)
|
||||||
|
- **Behaviour:** Types `[orchestrator] <message>\n` into the target child's PTY.
|
||||||
|
- **Returns:** `ok`.
|
||||||
|
|
||||||
|
#### `request_human_attention`
|
||||||
|
- **Args:** `child_id`, `reason` (string)
|
||||||
|
- **Behaviour:** Surfaces a notification in the TUI, blinks the sidebar entry for the child, optionally auto-focuses if the user setting allows it. Used by orchestrator when it wants to punt a decision (e.g. ambiguous permission prompt) to the human.
|
||||||
|
- **Returns:** `ok`.
|
||||||
|
|
||||||
|
### Tools available to all agents
|
||||||
|
|
||||||
|
#### `spawn_process`
|
||||||
|
- **Args:** One of:
|
||||||
|
- `preset` (string — name of a process preset under `$XDG_CONFIG_HOME/patterm/presets/processes/`), plus optional `working_dir?` / `env?` overrides; **or**
|
||||||
|
- `argv` (array of strings — freeform launch), with optional `working_dir?`, `env?`, and `shell?` (default `false`; when `true`, `argv` is interpreted as `["sh", "-lc", argv[0]]`-style).
|
||||||
|
- **Behaviour:** Launches the command in a new PTY, attached as a child of the calling agent's session. Presets are the preferred path; freeform `argv` is the escape hatch for one-offs the user hasn't pre-configured. No MCP injection (process children aren't agents).
|
||||||
|
- **Returns:** `child_id`.
|
||||||
|
|
||||||
|
#### `read_output`
|
||||||
|
- **Args:** `child_id`, `mode` (`grid` | `stream`), `since_offset?` (stream mode only)
|
||||||
|
- **Behaviour:**
|
||||||
|
- `grid` mode: returns the current rendered visible grid as plain text, ANSI stripped, with best-effort trimming of detectable vendor chrome (top banner, bottom input box, status line) per agent-type heuristics. Use for TUI children.
|
||||||
|
- `stream` mode: returns raw byte content from `since_offset` to current write head, ANSI stripped. Use for line-mode processes.
|
||||||
|
- **Returns:** `{ content: string, new_offset: int, mode: "grid" | "stream" }`.
|
||||||
|
- **Note in tool description (visible to the calling agent):** "The grid result is the entire visible pane. You are responsible for locating the response to your last prompt within it."
|
||||||
|
|
||||||
|
#### `send_input`
|
||||||
|
- **Args:** `child_id`, `input` (string), `append_newline?` (default `true`)
|
||||||
|
- **Behaviour:** Writes bytes to the child PTY's stdin. Used both for free-form input and for single-key confirmations (`y`, `n`).
|
||||||
|
- **Returns:** `ok`.
|
||||||
|
|
||||||
|
#### `kill`
|
||||||
|
- **Args:** `child_id`, `signal?` (default `SIGTERM`)
|
||||||
|
- **Returns:** `ok`.
|
||||||
|
|
||||||
|
#### `wait_for_pattern`
|
||||||
|
- **Args:** `child_id`, `pattern` (regex), `timeout_seconds`
|
||||||
|
- **Behaviour:** Blocks the calling agent until the rendered grid matches the regex or the timeout expires. Polls the grid at ~50ms intervals.
|
||||||
|
- **Returns:** `{ matched: bool, snippet?: string }`.
|
||||||
|
|
||||||
|
#### `timer_wait`
|
||||||
|
- **Args:** `seconds`, `label?` (default auto-generated)
|
||||||
|
- **Behaviour:** Returns immediately with a `timer_id`. After `seconds`, the tool injects `[system] Your timer [<label>] has completed.\n` into the calling agent's pane.
|
||||||
|
- **Returns:** `{ timer_id: string }`.
|
||||||
|
|
||||||
|
#### `list_children`
|
||||||
|
- **Args:** none
|
||||||
|
- **Returns:** Array of `{ child_id, name, type, status, exit_code? }` for the calling agent's session.
|
||||||
|
|
||||||
|
#### `scratchpad_list`
|
||||||
|
- **Returns:** Array of `{ name, size, modified_at }`.
|
||||||
|
|
||||||
|
#### `scratchpad_read`
|
||||||
|
- **Args:** `name`
|
||||||
|
- **Returns:** `{ content: string }`.
|
||||||
|
|
||||||
|
#### `scratchpad_write`
|
||||||
|
- **Args:** `name`, `content` (full replacement)
|
||||||
|
- **Returns:** `ok`.
|
||||||
|
|
||||||
|
#### `scratchpad_append`
|
||||||
|
- **Args:** `name`, `content`
|
||||||
|
- **Returns:** `ok`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Conversation protocol
|
||||||
|
|
||||||
|
patterm does **not** inject any framing or system-prompt text into spawned agents. Whatever an agent sees in its input is exactly what the user typed or what an orchestrator chose to send. The orchestrator (or the human launching it) is responsible for telling a spawned agent what its role is, what tools it has, and what conventions to expect.
|
||||||
|
|
||||||
|
That said, when patterm relays messages programmatically between agents or surfaces lifecycle events, it tags them so the receiving agent can distinguish sources. These tags are the patterm convention; agents will encounter them in their input and are expected to recognize them from context (or because their parent explained them in the initial prompt).
|
||||||
|
|
||||||
|
- `[orchestrator] <msg>` — prepended when `send_message_to` delivers a message from a parent to a child.
|
||||||
|
- `[sub-agent:<name>] <msg>` — prepended when `report_to_parent` delivers a message from a child to its parent.
|
||||||
|
- `[system] <msg>` — patterm itself (timer fires, child exited, etc.).
|
||||||
|
- Direct user typing is **not** prefixed. The user sees the pane and types normally; the agent receives the keystrokes as-is.
|
||||||
|
|
||||||
|
No "ready" handshake. patterm treats the agent as ready once its PTY hits the preset's `ready_signal` (default: 1s idle after launch — see §10). The very first thing the agent receives after that point is whatever the caller passed as `initial_prompt`.
|
||||||
|
|
||||||
|
Two-level tree only. Sub-agents cannot call `spawn_agent`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Permissions flow
|
||||||
|
|
||||||
|
Sub-agents are launched with vendor permissions **on** — the orchestrator drives their confirmation prompts.
|
||||||
|
|
||||||
|
Loop:
|
||||||
|
|
||||||
|
1. Orchestrator sends a message to a sub-agent via `send_message_to`.
|
||||||
|
2. Sub-agent runs, eventually hits a tool-use confirmation in its TUI ("Allow Bash(rm -rf foo)? [y/N]").
|
||||||
|
3. Sub-agent goes idle (cursor stops animating, no byte writes for 1s).
|
||||||
|
4. Orchestrator's loop calls `read_output(child_id, mode="grid")`, sees the prompt, decides, and calls `send_input(child_id, "y")` or `"n"`.
|
||||||
|
5. If the orchestrator can't safely decide, it calls `request_human_attention(child_id, "Sub-agent wants to run X, looks destructive, need your call")`. The orchestrator then waits (using `wait_for_pattern` or repeated reads) until the prompt is no longer on screen.
|
||||||
|
|
||||||
|
Risks acknowledged: the orchestrator's reading of the prompt is a vision/parsing problem on rendered text. We trust a SOTA model to handle this correctly. The `request_human_attention` punt is the safety valve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Presets
|
||||||
|
|
||||||
|
Presets are user-editable JSON files that describe how to launch something. patterm itself has no hard-coded agent or process types — every spawnable thing is a preset. Two flavours:
|
||||||
|
|
||||||
|
### Agent presets
|
||||||
|
|
||||||
|
`$XDG_CONFIG_HOME/patterm/presets/agents/<name>.json`. Launches a vendor LLM CLI with MCP wired up and the conversation-protocol addendum injected.
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `name` | Display name shown in the palette (e.g. "claude", "codex haiku", "opencode-experimental") |
|
||||||
|
| `argv` | Full launch argv (e.g. `["claude"]`, `["codex", "--no-tui-banner"]`) |
|
||||||
|
| `env` | Env vars to set (merged over inherited env) |
|
||||||
|
| `working_dir` | Defaults to the project root |
|
||||||
|
| `mcp_injection` | How to point this CLI at patterm's stdio proxy. One of: `{ "kind": "flag", "flag": "--mcp-config", "config_path": "..." }`, `{ "kind": "config_file", "path": "~/.codex/config.toml", "merge_key": "mcp_servers" }`, `{ "kind": "env_var", "var": "MCP_CONFIG_PATH" }` |
|
||||||
|
| `ready_signal` | How to detect the TUI is ready (default: 1s idle after launch). Override per-CLI if needed. |
|
||||||
|
| `chrome_trim_hints` | Optional regexes / row ranges for stripping vendor chrome in grid reads |
|
||||||
|
|
||||||
|
Default presets shipped: `claude`, `codex`, `opencode`. Authoring these is per-vendor research — each CLI has its own MCP config conventions, ready states, and TUI chrome. Users can copy and edit them, or add new ones (e.g. a second `claude` preset that launches with a specific model or system prompt file).
|
||||||
|
|
||||||
|
MCP config flow: at startup, for each agent preset, patterm renders a small JSON pointing at its own `mcp-stdio` proxy subcommand (`patterm mcp-stdio --socket <pid-sock> --identity <token>`) into a per-preset temp file. The launch then uses the preset's `mcp_injection` strategy to hand that path to the CLI. The user's global vendor config is never mutated.
|
||||||
|
|
||||||
|
### Process presets
|
||||||
|
|
||||||
|
`$XDG_CONFIG_HOME/patterm/presets/processes/<name>.json`. Launches a raw command in a PTY — no MCP, no addendum, no system prompt.
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `name` | Display name shown in the palette (e.g. "bun run dev") |
|
||||||
|
| `argv` | Launch argv (e.g. `["bun", "run", "dev"]`) |
|
||||||
|
| `shell` | If `true`, argv is interpreted via `sh -lc`. Default `false`. |
|
||||||
|
| `env` | Env vars to set |
|
||||||
|
| `working_dir` | Defaults to the project root |
|
||||||
|
|
||||||
|
Process presets are intentionally thin: they're shortcuts for commands the user runs often. Anything more exotic — pipelines, redirections — uses `shell: true`, or the orchestrator can call `spawn_process` with freeform argv.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Done-signal heuristic
|
||||||
|
|
||||||
|
A pane is considered "idle" when no bytes have been written to its PTY's master end for **1000 ms**.
|
||||||
|
|
||||||
|
Rationale: every supported vendor TUI animates a spinner while busy (during LLM streaming and during tool execution). A genuinely idle pane stops animating.
|
||||||
|
|
||||||
|
Caveats and mitigations:
|
||||||
|
|
||||||
|
- LLM provider hiccups can cause >1s gaps mid-stream. Per-agent tuning of the idle threshold is allowed in the preset.
|
||||||
|
- Orchestrators should treat idle as a signal to *read*, not as a guarantee of completion. If the read returns something ambiguous, they can `wait_for_pattern` with a known terminal marker (e.g. the agent's input prompt) for stronger evidence.
|
||||||
|
- The tool exposes idle state via `list_children` so orchestrators don't need to poll byte streams directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Failure modes
|
||||||
|
|
||||||
|
| Failure | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| Sub-agent process exits unexpectedly | Sidebar marks child as exited, exit code preserved. Orchestrator's next `read_output` returns final grid + exit metadata. |
|
||||||
|
| Vendor CLI hangs without exiting | Looks idle. Orchestrator must use `wait_for_pattern` or `request_human_attention` to escape. |
|
||||||
|
| Tool process crashes | All PTYs are children of the tool's process group; OS cleans them up (process-group SIGHUP on terminal close, PTY master close, parent-death signal on Linux). On macOS treat cleanup as best-effort; scratchpads on disk survive. |
|
||||||
|
| User closes the terminal window / SSH drops | Process receives SIGHUP, cascades SIGTERM → SIGKILL to every child, exits. Everything inside the tool dies with it. This is the intended model. |
|
||||||
|
| Disk full on scratchpad write | Tool returns error to caller. |
|
||||||
|
| LLM provider network blip | Pane idles, may trigger false "done" — orchestrator should sanity-check responses. |
|
||||||
|
| User kills the orchestrator pane | Tool detects PTY close, cascades SIGTERM to that session's children. |
|
||||||
|
| Concurrent input | Bytes interleave on PTY stdin. Toast warns. User's call. |
|
||||||
|
| Vt emulator bug on exotic ANSI | Grid rendering corrupts for that pane. Orchestrator's read will be noisy; degrade gracefully, don't crash. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Out of scope for v1
|
||||||
|
|
||||||
|
- Cross-project orchestration.
|
||||||
|
- Sub-agents spawning sub-agents (trees deeper than 2).
|
||||||
|
- Daemonized / detachable sessions surviving the terminal window. The tool is intentionally bound to the user's foreground process.
|
||||||
|
- Multi-client attach to a single session.
|
||||||
|
- Native ACP support (PTY scraping only).
|
||||||
|
- Hosting any LLM internally.
|
||||||
|
- Auth beyond OS-level file permissions on the IPC socket and state dir.
|
||||||
|
- Web / API control surface.
|
||||||
|
- Recording / replay of sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Open questions
|
||||||
|
|
||||||
|
- **Vt emulator library.** Resolved in the closing note — `libghostty-vt` is the bet, with `vt10x` / `charmbracelet/x/vt` as fallback only.
|
||||||
|
- **MCP transport.** Resolved — in-process MCP core with a `mcp-stdio` proxy subcommand for spawned children (see §7 and §10). Streamable HTTP can be added later.
|
||||||
|
- **Scratchpad concurrency.** Two agents writing the same scratchpad: last-write-wins with a revision token (see addendum item 7 in the closing note). Agents are expected to coordinate.
|
||||||
|
- **Default presets that ship in the box.** claude / codex / opencode is the working set; trimming to two for the first cut is fine since presets are user-editable anyway.
|
||||||
|
- **Per-project preset overrides.** v1 has a single global preset directory. Whether `./.patterm/presets/` should override per-project is a v2 question.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Suggested build order
|
||||||
|
|
||||||
|
1. Single-process skeleton: TUI bootstraps, owns the terminal, handles SIGWINCH / SIGHUP / SIGTERM, exits cleanly.
|
||||||
|
2. Single PTY per session + vt emulator + tab bar UI + basic input/render.
|
||||||
|
3. Multi-session, multi-child (sidebar) with raw process spawning, process groups, kill cascade on exit (no MCP yet).
|
||||||
|
4. In-process MCP server + `mcp-stdio` proxy subcommand + per-PID unix socket + `spawn_process` / `read_output` / `send_input` / `kill` / `wait_for_pattern`.
|
||||||
|
5. `spawn_agent` preset for one agent (probably claude), conversation tag conventions, `initial_prompt` injection (typed into the TUI input after ready).
|
||||||
|
6. Scratchpads, `timer_wait`, `request_human_attention`, `send_message_to`, `report_to_parent`.
|
||||||
|
7. Second and third agent presets, chrome-trim heuristics.
|
||||||
|
8. Polish: command palette, status indicators, error UX.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Yes — use `libghostty-vt` for the terminal emulation layer. Not full Ghostty, and not as a UI renderer. Use it as a headless VT state machine inside the tool process, wrapped behind your own Go interface.
|
||||||
|
|
||||||
|
`libghostty-vt` is basically aimed at exactly your load-bearing problem: it is a C library extracted from Ghostty that handles VT parsing, terminal state, scrollback, line wrapping, resize reflow, input event encoding, and related terminal internals. The current docs also warn that the API is still unstable, so this should be a pinned dependency, not something you casually track at HEAD. ([libghostty.tip.ghostty.org][1])
|
||||||
|
|
||||||
|
The right move is:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Emulator interface {
|
||||||
|
WritePTYOutput([]byte)
|
||||||
|
Resize(cols, rows uint16)
|
||||||
|
PlainText() string
|
||||||
|
Cell(x, y int) Cell
|
||||||
|
Cursor() Cursor
|
||||||
|
ActiveScreen() Screen
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then implement `GhosttyEmulator` behind that. Keep `vt10x` or `charmbracelet/x/vt` as experimental/fallback only. `vt10x` is pure Go and convenient, but its own package docs describe it as “in development”; Charm’s `x` repo is explicitly experimental with no backwards-compatibility promise. For this project, terminal fidelity is not a nice-to-have; it is the product. ([Go Packages][2])
|
||||||
|
|
||||||
|
The best part: `libghostty-vt` already has formatter support for producing plain text from the active screen, which maps cleanly to your `read_output(mode="grid")`; it also exposes key and mouse encoding, which matters once you stop only typing ASCII strings and start needing arrows, Ctrl-C, Tab, Escape, mouse passthrough, and Kitty keyboard protocol support. ([libghostty.tip.ghostty.org][3])
|
||||||
|
|
||||||
|
The catch: cgo/build packaging becomes real. Pin a commit, vendor or checksum the library, and put all C ABI calls in one internal package. Do not scatter cgo across the codebase.
|
||||||
|
|
||||||
|
Big spec changes I’d make before building:
|
||||||
|
|
||||||
|
First, change MCP transport strategy. Implement the in-process MCP core once, then expose it via a tiny stdio proxy subcommand:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
patterm mcp-stdio --socket "$SOCK" --identity "$TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Each spawned agent gets an MCP config pointing at that command. The vendor CLI thinks it is launching a normal stdio MCP server; the proxy forwards JSON-RPC to the running tool process over its per-PID Unix socket. This avoids relying on every CLI supporting HTTP over Unix sockets, gives you clean per-agent identity, and keeps the tool process as the single owner of state.
|
||||||
|
|
||||||
|
Still support Streamable HTTP later, but stdio-proxy-first is more robust for local CLIs. MCP currently defines stdio and Streamable HTTP as standard transports, and Claude Code, Codex, and OpenCode all expose MCP configuration paths that can work with local or HTTP-style servers. ([Model Context Protocol][4])
|
||||||
|
|
||||||
|
Second, remove the generic `MCP_CONFIG_PATH` assumption. Each preset needs real vendor-specific MCP config handling. Claude Code supports `--mcp-config` and `--strict-mcp-config`. ([Claude][5]) Codex config uses `~/.codex/config.toml` / project `.codex/config.toml`, with `mcp_servers.<id>.command` for stdio and `mcp_servers.<id>.url` for HTTP. ([OpenAI Developers][6]) OpenCode exposes MCP through its `mcp` config option and `opencode mcp add`, so that preset needs its own path too. ([OpenCode][7])
|
||||||
|
|
||||||
|
Third, add a child-to-parent MCP tool. Your conversation protocol mentions `[sub-agent:<name>]` messages reporting back, but the tool surface does not currently include a way for a sub-agent to send one. Add:
|
||||||
|
|
||||||
|
```text
|
||||||
|
report_to_parent(message: string) -> ok
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the tool injects:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[sub-agent:codex-2] <message>
|
||||||
|
```
|
||||||
|
|
||||||
|
into the parent orchestrator pane. Without this, the orchestrator has to scrape the child forever, which is workable but worse.
|
||||||
|
|
||||||
|
Fourth, change `spawn_process(command: string)` to an argv form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"argv": ["bun", "run", "dev"],
|
||||||
|
"working_dir": ".",
|
||||||
|
"env": {},
|
||||||
|
"shell": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Let agents explicitly request shell mode:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"argv": ["sh", "-lc", "bun run dev | tee /tmp/dev.log"],
|
||||||
|
"shell": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A raw command string is quoting hell and makes policy inspection harder.
|
||||||
|
|
||||||
|
Fifth, make permission handling more conservative. The orchestrator reading a rendered confirmation prompt is useful, but it is not a safety boundary. A malicious repo or child process can print misleading prompt-like text. Default policy should be: auto-answer only boring, allowlisted prompts; punt writes, deletes, network exfiltration, credential access, `sudo`, package install scripts, and broad shell commands to the human. OpenCode’s own docs say operations are allowed by default unless permissions are configured, so per-agent recipe permissions need to be deliberate rather than assumed safe. ([OpenCode][7])
|
||||||
|
|
||||||
|
Sixth, child cleanup on tool exit must be real. There is no daemon to keep PTYs alive — but the OS will not magically reap children either. Put every spawned PTY in the tool's process group (or a dedicated sub-group), set Linux `PR_SET_PDEATHSIG` on children, close PTY masters on exit, and install a SIGHUP/SIGTERM handler that runs the SIGTERM→grace→SIGKILL cascade before the process actually exits. On macOS, parent-death signals don't exist; rely on process-group SIGHUP and PTY master close, and treat any straggler cleanup as best-effort. A stale-process sweep on next startup is unnecessary now that there is no daemon to outlive its children.
|
||||||
|
|
||||||
|
Seventh, revise `send_input`. Text plus `append_newline` is too weak. You need:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "text" | "paste" | "key",
|
||||||
|
"text": "...",
|
||||||
|
"key": "enter|tab|escape|ctrl-c|left|right|up|down",
|
||||||
|
"submit": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use bracketed paste for multi-line prompt injection where the target TUI supports it. Otherwise multi-line prompts can accidentally submit partial content.
|
||||||
|
|
||||||
|
Eighth, expose more metadata in `read_output`. Return row numbers, active screen, cursor position, idle state, process status, and maybe a `screen_version`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "...",
|
||||||
|
"mode": "grid",
|
||||||
|
"active_screen": "alternate",
|
||||||
|
"rows": 38,
|
||||||
|
"cols": 120,
|
||||||
|
"cursor": {"x": 4, "y": 37},
|
||||||
|
"idle_ms": 1420,
|
||||||
|
"screen_version": 9182,
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Models are better at parsing when you give them stable structure.
|
||||||
|
|
||||||
|
For `libghostty-vt`, the implementation detail that matters most is effects. The docs say VT processing handles terminal state by default, but side-effect sequences such as bell, title changes, device queries, and write-back responses need configured callbacks; those callbacks are synchronous and should not block. Wire at least `WRITE_PTY`, bell, title, size/query responses, and active-screen tracking early. ([libghostty.tip.ghostty.org][8])
|
||||||
|
|
||||||
|
Recommended revised build order:
|
||||||
|
|
||||||
|
1. PTY + `libghostty-vt` spike before any UI work. Spawn `bash`, `vim`, `htop`, Claude/Codex/OpenCode if installed, feed output into Ghostty, dump plain grid. This either validates the core bet or kills it early.
|
||||||
|
|
||||||
|
2. Single-process TUI with one PTY session. SIGWINCH-driven resize from the tool's own terminal. No MCP yet.
|
||||||
|
|
||||||
|
3. Raw child process spawning, sidebar, process groups, kill cascade on exit/SIGHUP, idle detection.
|
||||||
|
|
||||||
|
4. MCP stdio proxy subcommand and core tools: `spawn_process`, `read_output`, `send_input`, `kill`, `list_children`.
|
||||||
|
|
||||||
|
5. One orchestrator preset, probably Claude first because it has useful CLI flags for MCP config. Use `--mcp-config` and `--strict-mcp-config` so the user's global Claude config isn't mutated. ([Claude][5])
|
||||||
|
|
||||||
|
6. `spawn_agent`, `report_to_parent`, `send_message_to`, and timer injection.
|
||||||
|
|
||||||
|
7. Scratchpads with revision IDs. Last-write-wins is okay for v1, but return a revision so agents can avoid blind overwrites:
|
||||||
|
|
||||||
|
```json
|
||||||
|
scratchpad_read -> { "content": "...", "revision": "abc123" }
|
||||||
|
scratchpad_write -> { "content": "...", "expected_revision": "abc123" }
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Second and third recipes. Keep recipe files declarative, but expect custom Go code for each vendor.
|
||||||
|
|
||||||
|
9. Chrome trimming heuristics and golden tests using recorded VT byte streams from each supported CLI.
|
||||||
|
|
||||||
|
One more practical point: put scratchpads under XDG data, not config. Something like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$XDG_DATA_HOME/patterm/projects/<key>/scratchpads/
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep spawn recipes/config under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$XDG_CONFIG_HOME/patterm/
|
||||||
|
```
|
||||||
|
|
||||||
|
Scratchpads are user data, not configuration. Not fatal, but fixing it now avoids awkward migration later.
|
||||||
|
|
||||||
|
Overall: the concept is buildable, but the hard parts are not MCP or the TUI chrome. The hard parts are terminal fidelity, process lifecycle, vendor recipe drift, and permission safety. `libghostty-vt` is the right core bet, provided you isolate it behind an interface and treat its unstable API as a vendored implementation detail.
|
||||||
|
|
||||||
|
[1]: https://libghostty.tip.ghostty.org/ "libghostty: libghostty-vt - Virtual Terminal Emulator Library"
|
||||||
|
[2]: https://pkg.go.dev/github.com/micro-editor/terminal "terminal package - github.com/micro-editor/terminal - Go Packages"
|
||||||
|
[3]: https://libghostty.tip.ghostty.org/group__formatter.html "libghostty: Formatter"
|
||||||
|
[4]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports "Transports - Model Context Protocol"
|
||||||
|
[5]: https://code.claude.com/docs/en/cli-reference "CLI reference - Claude Code Docs"
|
||||||
|
[6]: https://developers.openai.com/codex/config-reference "Configuration Reference – Codex | OpenAI Developers"
|
||||||
|
[7]: https://opencode.ai/docs/config/ "Config | OpenCode"
|
||||||
|
[8]: https://libghostty.tip.ghostty.org/group__terminal.html "libghostty: Terminal"
|
||||||
128
SPIKE-REPORT.md
Normal file
128
SPIKE-REPORT.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Milestone 1 Spike Report — libghostty-vt
|
||||||
|
|
||||||
|
**Date:** 2026-05-12
|
||||||
|
**Pin:** `ghostty-org/ghostty@b0f8276658fbcc75318d2125d40146074a3fc505` (main, post-v1.3.1)
|
||||||
|
**Spike binary:** `./bin/spike` (built against the static `libghostty-vt.a`)
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
✅ **The libghostty-vt bet is validated.** Proceed to milestone 2 (daemon/client
|
||||||
|
singleton + single PTY).
|
||||||
|
|
||||||
|
Every target except `htop` (not installed locally, deferred) rendered correctly.
|
||||||
|
The libghostty-vt-emulated grid matched the live host-terminal display cell-for-cell
|
||||||
|
across plain stream output, interactive bash, alt-screen TUIs (vim), and the three
|
||||||
|
agent CLIs that are the actual point of the project (claude, opencode, codex).
|
||||||
|
|
||||||
|
## Target matrix
|
||||||
|
|
||||||
|
| # | Target | Screen | Verdict | Grid log | PTY bytes |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | `sh -c 'echo hello; sleep 1'` | primary | ✅ | `spike-201533.grid.log` | `spike-201533.bytes` |
|
||||||
|
| 2 | `bash -i` | primary | ✅ | `spike-201606.grid.log` | `spike-201606.bytes` |
|
||||||
|
| 3 | `vim SPEC.md` | alt → primary | ✅ | `spike-201707.grid.log` | `spike-201707.bytes` |
|
||||||
|
| 4 | `htop` | — | ⊘ deferred | not run | not run |
|
||||||
|
| 5 | `claude` | primary | ✅ | `spike-201848.grid.log` | `spike-201848.bytes` |
|
||||||
|
| 6 | `opencode` | alt | ✅ | `spike-202380.grid.log` | `spike-202380.bytes` |
|
||||||
|
| 7 | `codex` | primary | ✅ | `spike-202614.grid.log` | `spike-202614.bytes` |
|
||||||
|
|
||||||
|
### Notable observations per target
|
||||||
|
|
||||||
|
- **vim**: alt-screen entry/exit tracked correctly; on `:q` returned to `primary` with
|
||||||
|
empty grid and cursor at `(0,0)`. The formatter's `unwrap=true` setting reassembled
|
||||||
|
soft-wrapped paragraphs from SPEC.md into long single-line paragraphs — this is
|
||||||
|
exactly the shape an orchestrator agent wants when reading sub-agent output.
|
||||||
|
- **claude** and **codex** render on the **primary** screen, not alt-screen. They draw
|
||||||
|
their own cursor (`visible=false`). Chrome heuristics in milestone 7 will need to be
|
||||||
|
per-agent, not per-screen-buffer.
|
||||||
|
- **opencode** uses alt-screen and a heavy bar-art logo that visually looks "broken"
|
||||||
|
on first glance — it isn't. The grid dump confirms cell-perfect parsing.
|
||||||
|
- **bash**: cooked PTY line discipline is doing its job — `echo test` → `test` →
|
||||||
|
`[harry@…]$ exit` → `exit` all sequence correctly in the final dump, cursor at (0,4).
|
||||||
|
|
||||||
|
## API surface used
|
||||||
|
|
||||||
|
From `include/ghostty/vt/`:
|
||||||
|
|
||||||
|
- `ghostty_terminal_new` / `ghostty_terminal_free`
|
||||||
|
- `ghostty_terminal_vt_write` — feed PTY bytes
|
||||||
|
- `ghostty_terminal_resize(cols, rows, 0, 0)` — pixel dims ignored for headless
|
||||||
|
- `ghostty_terminal_set` for: `USERDATA`, `WRITE_PTY`, `DEVICE_ATTRIBUTES`,
|
||||||
|
`XTVERSION`, `ENQUIRY`
|
||||||
|
- `ghostty_terminal_get` for: `CURSOR_X`, `CURSOR_Y`, `CURSOR_VISIBLE`, `ACTIVE_SCREEN`
|
||||||
|
- `ghostty_formatter_terminal_new` + `ghostty_formatter_format_alloc` +
|
||||||
|
`ghostty_formatter_free` + `ghostty_free` (allocator-aware)
|
||||||
|
- Format options: `FORMAT_PLAIN`, `unwrap=true`, `trim=true`
|
||||||
|
|
||||||
|
Everything we needed was present and stable enough to use behind a Go interface.
|
||||||
|
|
||||||
|
## Bugs found and fixed during the spike
|
||||||
|
|
||||||
|
1. **v1.3.1 tag didn't yet expose `terminal.h` / `formatter.h`.** Bumped the pin
|
||||||
|
forward to a commit on `main` where the full API is published.
|
||||||
|
2. **Recursive-mutex deadlock** between `Emulator.Write` and the `WRITE_PTY` cgo
|
||||||
|
callback (Write held `e.mu`, callback re-entered Go and tried to take `e.mu`
|
||||||
|
again). Switched the callback field to `atomic.Pointer`.
|
||||||
|
3. **Vim hung on startup.** Missing `DEVICE_ATTRIBUTES` callback meant DA1 queries
|
||||||
|
(`CSI c`) were silently ignored; vim waited forever. Now we respond with a
|
||||||
|
constant VT220-class identity (conformance 62, no features). Added stub
|
||||||
|
`XTVERSION` and `ENQUIRY` responders for the same reason.
|
||||||
|
4. **Raw-mode stderr produced ragged output**, with subsequent lines starting
|
||||||
|
mid-line. After `term.MakeRaw`, `OPOST` is off, so `\n` doesn't generate a CR.
|
||||||
|
Changed all spike stderr writes to `\r\n`.
|
||||||
|
5. **Grid dumps to stderr visually corrupted alt-screen TUIs.** The libghostty-vt
|
||||||
|
grid was always correct; the host terminal display was getting smashed because
|
||||||
|
the spike was writing 40+ lines onto a display owned by the TUI. Default sink
|
||||||
|
is now `spike-<pid>.grid.log`; user tails it from another terminal.
|
||||||
|
6. **Idle-dump breadcrumbs were chatty** — fired once per ~1 s of typing. Now only
|
||||||
|
hotkey dumps emit a breadcrumb, and only when the child is on the primary screen.
|
||||||
|
|
||||||
|
## Resolved open questions from the plan
|
||||||
|
|
||||||
|
- ✅ `ghostty_terminal_resize` exists. Takes pixel dimensions too; we pass `0, 0`.
|
||||||
|
- ✅ Build system is Zig (≥0.15.2). Downloaded the official tarball into
|
||||||
|
`.zig-cache/` and the `Makefile` `make deps` target produced static + shared libs
|
||||||
|
on first try. Pacman's zig 0.16.0 was not tested; not needed.
|
||||||
|
- ✅ Default `WRITE_PTY` callback is sufficient for DECRQM responses, but `DA1`,
|
||||||
|
`XTVERSION`, `ENQ` need their own handlers or vim and friends hang. Wired up
|
||||||
|
constant responders for all three.
|
||||||
|
- ✅ Active vs alternate screen tracking via `GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN`
|
||||||
|
is reliable across all three agent CLIs.
|
||||||
|
- ◻ Formatter cost per dump: each `PlainText()` call allocates and frees a buffer.
|
||||||
|
Acceptable for the spike. **Action for daemon era:** cache one formatter handle
|
||||||
|
per emulator instead of recreating on every dump.
|
||||||
|
|
||||||
|
## Risks / follow-ups for milestone 2
|
||||||
|
|
||||||
|
1. **Per-agent chrome trimming** (`SPEC.md §10`): claude and codex render on
|
||||||
|
primary screen, so we can't just "skip the alt-screen". Chrome-trim heuristics
|
||||||
|
will need to identify banner/input-box regions by content, not by screen buffer.
|
||||||
|
The `.bytes` recordings in this run are good fixtures for that work.
|
||||||
|
2. **Resize timing.** Spike resizes both PTY and emulator on SIGWINCH. Daemon will
|
||||||
|
have one emulator per pane and the spec's "primary-client-wins" policy must be
|
||||||
|
enforced before any UI work.
|
||||||
|
3. **Reading-back the spike report after the run.** The matrix script's stderr
|
||||||
|
capture (`2> >(tee -a "$REPORT" >&2)`) interleaved per-case lines awkwardly
|
||||||
|
(visible in `spike-report-20260512T154705.txt`). Cosmetic; doesn't affect
|
||||||
|
evaluation.
|
||||||
|
|
||||||
|
## Reproducing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# One-time
|
||||||
|
make deps # zig builds libghostty-vt.a
|
||||||
|
make spike # build ./bin/spike
|
||||||
|
|
||||||
|
# Single target
|
||||||
|
./bin/spike -- claude # then Ctrl-] to dump
|
||||||
|
tail -f spike-<pid>.grid.log # in another terminal
|
||||||
|
|
||||||
|
# Full matrix
|
||||||
|
./cmd/spike/testdata/run-matrix.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Proceed with libghostty-vt as the headless VT for milestone 2. Keep the cgo wrapper
|
||||||
|
behind `internal/vt.Emulator` so the fallback (`charmbracelet/x/vt`, `vt10x`) stays
|
||||||
|
swappable, but no longer treat it as a real risk for v1.
|
||||||
79
cmd/patterm/main.go
Normal file
79
cmd/patterm/main.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// patterm is a terminal-based agent orchestration shell. SPEC §2: one
|
||||||
|
// foreground process owns the TUI, every PTY, and the in-process MCP
|
||||||
|
// server. Closing the terminal window ends the session.
|
||||||
|
//
|
||||||
|
// patterm run in $PWD
|
||||||
|
// patterm --project <dir> run in <dir>
|
||||||
|
// patterm mcp-stdio --socket S --identity I
|
||||||
|
// internal: stdio MCP proxy spawned for
|
||||||
|
// children, forwards JSON-RPC over S
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/app"
|
||||||
|
"github.com/harrybrwn/patterm/internal/mcp"
|
||||||
|
"github.com/harrybrwn/patterm/internal/projectkey"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// The mcp-stdio subcommand is a separate top-level mode: when an
|
||||||
|
// agent CLI launches `patterm mcp-stdio --socket ...`, the same
|
||||||
|
// binary forwards JSON-RPC to the running process over the per-PID
|
||||||
|
// socket. SPEC §10.
|
||||||
|
if len(os.Args) >= 2 && os.Args[1] == "mcp-stdio" {
|
||||||
|
os.Args = append(os.Args[:1], os.Args[2:]...)
|
||||||
|
runMCPProxy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectDir = flag.String("project", "", "project directory (default $PWD)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
die("getwd: %v", err)
|
||||||
|
}
|
||||||
|
if *projectDir != "" {
|
||||||
|
cwd = *projectDir
|
||||||
|
}
|
||||||
|
key, err := projectkey.Key(cwd)
|
||||||
|
if err != nil {
|
||||||
|
die("project key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chdir(cwd); err != nil {
|
||||||
|
die("chdir %s: %v", cwd, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := app.Run(ctx, app.Options{
|
||||||
|
ProjectDir: cwd,
|
||||||
|
ProjectKey: key,
|
||||||
|
}); err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMCPProxy() {
|
||||||
|
var (
|
||||||
|
socket = flag.String("socket", "", "path to the running patterm process's MCP socket")
|
||||||
|
identity = flag.String("identity", "", "per-child identity token")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
if *socket == "" || *identity == "" {
|
||||||
|
die("mcp-stdio: --socket and --identity are required")
|
||||||
|
}
|
||||||
|
if err := mcp.RunStdioProxy(*socket, *identity); err != nil {
|
||||||
|
die("mcp-stdio: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func die(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "patterm: "+format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
390
cmd/spike/main.go
Normal file
390
cmd/spike/main.go
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
// cmd/spike is the milestone-1 throwaway: spawn a child in a PTY, pump bytes
|
||||||
|
// through a libghostty-vt-backed emulator, and dump the rendered grid as
|
||||||
|
// plain text on idle or hotkey.
|
||||||
|
//
|
||||||
|
// Stdin from the host terminal is forwarded raw to the child PTY, so vim,
|
||||||
|
// htop, claude, codex and friends behave as if you ran them directly. We are
|
||||||
|
// explicitly NOT encoding keys ourselves yet — that's a daemon-era concern.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/pty"
|
||||||
|
"github.com/harrybrwn/patterm/internal/vt"
|
||||||
|
|
||||||
|
cpty "github.com/creack/pty"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultCols = 120
|
||||||
|
defaultRows = 40
|
||||||
|
defaultIdleMS = 1000
|
||||||
|
readBufferBytes = 64 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// Known hotkey aliases mapped to their raw control bytes.
|
||||||
|
var hotkeyAliases = map[string]byte{
|
||||||
|
"ctrl-]": 0x1d, // GS — default, but some layouts/terminals swallow it
|
||||||
|
"ctrl-\\": 0x1c, // FS — sends SIGQUIT in cooked mode but raw passes through
|
||||||
|
"ctrl-^": 0x1e, // RS
|
||||||
|
"ctrl-_": 0x1f, // US
|
||||||
|
"ctrl-t": 0x14,
|
||||||
|
"ctrl-o": 0x0f,
|
||||||
|
"ctrl-space": 0x00, // NUL
|
||||||
|
}
|
||||||
|
|
||||||
|
type spikeFlags struct {
|
||||||
|
cols, rows int
|
||||||
|
idleMS int
|
||||||
|
followHost bool
|
||||||
|
noPassthrough bool
|
||||||
|
bytesPath string
|
||||||
|
gridPath string
|
||||||
|
gridToStderr bool
|
||||||
|
hotkey string
|
||||||
|
debugStdin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var f spikeFlags
|
||||||
|
flag.IntVar(&f.cols, "cols", defaultCols, "PTY columns (overridden by host size if -follow-host)")
|
||||||
|
flag.IntVar(&f.rows, "rows", defaultRows, "PTY rows (overridden by host size if -follow-host)")
|
||||||
|
flag.IntVar(&f.idleMS, "dump-after-idle", defaultIdleMS, "dump grid to stderr after this many ms of PTY silence (0 disables)")
|
||||||
|
flag.BoolVar(&f.followHost, "follow-host", true, "use the host terminal's size and follow SIGWINCH")
|
||||||
|
flag.BoolVar(&f.noPassthrough, "no-stdin", false, "don't forward host stdin to the child PTY")
|
||||||
|
flag.StringVar(&f.bytesPath, "bytes-out", "", "tee raw PTY bytes to this file (default: spike-<pid>.bytes when child starts)")
|
||||||
|
flag.StringVar(&f.gridPath, "grid-out", "", "write grid dumps to this file (default: spike-<pid>.grid.log). Use - for stderr (will visually corrupt alt-screen TUIs).")
|
||||||
|
flag.BoolVar(&f.gridToStderr, "grid-stderr", false, "also echo each grid dump to stderr. Convenient for non-TUI children (echo, bash); avoid with vim/htop/agent CLIs.")
|
||||||
|
flag.StringVar(&f.hotkey, "hotkey", "ctrl-]", "key chord that triggers a grid dump: ctrl-], ctrl-\\, ctrl-^, ctrl-_, ctrl-t, ctrl-o, ctrl-space")
|
||||||
|
flag.BoolVar(&f.debugStdin, "debug-stdin", false, "log every stdin byte to stderr as we read it (for working out what your terminal sends)")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "usage: spike [flags] -- <argv>\n\nflags:\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
fmt.Fprintf(os.Stderr, "\nWhile running, press the configured -hotkey to dump the grid.\nDefault sink is spike-<pid>.grid.log; tail -f it in another terminal.\n")
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
argv := flag.Args()
|
||||||
|
if len(argv) == 0 {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
hotkey, ok := hotkeyAliases[strings.ToLower(f.hotkey)]
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintf(os.Stderr, "spike: unknown -hotkey %q (see -h for options)\n", f.hotkey)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
startCols, startRows := uint16(f.cols), uint16(f.rows)
|
||||||
|
if f.followHost {
|
||||||
|
if c, r, ok := hostSize(); ok {
|
||||||
|
startCols, startRows = c, r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := run(argv, startCols, startRows, f.idleMS, f.followHost, !f.noPassthrough, f.bytesPath, f.gridPath, f.gridToStderr, hotkey, f.debugStdin); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "spike: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(argv []string, cols, rows uint16, idleMS int, followHost, stdinPassthrough bool, bytesPath, gridPath string, gridToStderr bool, hotkey byte, debugStdin bool) error {
|
||||||
|
em, err := vt.NewGhosttyEmulator(cols, rows)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("emulator: %w", err)
|
||||||
|
}
|
||||||
|
defer em.Close()
|
||||||
|
|
||||||
|
child, err := pty.Start(argv, nil, cols, rows)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pty: %w", err)
|
||||||
|
}
|
||||||
|
defer child.Close()
|
||||||
|
|
||||||
|
// Wire WRITE_PTY back to the child's stdin so DA/DSR query responses
|
||||||
|
// reach the program asking.
|
||||||
|
em.OnWritePTY(func(b []byte) {
|
||||||
|
if _, werr := child.Write(b); werr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\nspike: write_pty back to child failed: %v\r\n", werr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set up the bytes tee.
|
||||||
|
if bytesPath == "" {
|
||||||
|
bytesPath = fmt.Sprintf("spike-%d.bytes", child.Pid())
|
||||||
|
}
|
||||||
|
bytesFile, err := os.Create(bytesPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bytes tee: %w", err)
|
||||||
|
}
|
||||||
|
defer bytesFile.Close()
|
||||||
|
|
||||||
|
// Set up the grid sink. By default this is a file, not stderr, because
|
||||||
|
// writing a multi-line dump to the host terminal while an alt-screen TUI
|
||||||
|
// owns it visually corrupts the host display (the TUI inside the PTY is
|
||||||
|
// fine; libghostty-vt's grid is fine; only the host's render breaks).
|
||||||
|
var gridSink *os.File
|
||||||
|
gridIsStderr := false
|
||||||
|
switch gridPath {
|
||||||
|
case "-":
|
||||||
|
gridSink = os.Stderr
|
||||||
|
gridIsStderr = true
|
||||||
|
case "":
|
||||||
|
gridPath = fmt.Sprintf("spike-%d.grid.log", child.Pid())
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
gridSink, err = os.Create(gridPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("grid log: %w", err)
|
||||||
|
}
|
||||||
|
defer gridSink.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "spike: child pid=%d, bytes=%s, grid=%s (%dx%d)\r\n",
|
||||||
|
child.Pid(), bytesPath, gridPath, cols, rows)
|
||||||
|
if !gridIsStderr {
|
||||||
|
fmt.Fprintf(os.Stderr, "spike: tail -f %s in another terminal to watch dumps live\r\n", gridPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set host stdin to raw mode so key sequences (arrows, Ctrl-C, etc.)
|
||||||
|
// reach the child intact. Save the state for restore.
|
||||||
|
var restoreState *term.State
|
||||||
|
if stdinPassthrough && term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
st, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdin raw: %w", err)
|
||||||
|
}
|
||||||
|
restoreState = st
|
||||||
|
defer term.Restore(int(os.Stdin.Fd()), restoreState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle detection: PTY reader updates lastWrite; ticker checks if we
|
||||||
|
// crossed the threshold without writes and prints a grid dump.
|
||||||
|
var lastWriteNS atomic.Int64
|
||||||
|
lastWriteNS.Store(time.Now().UnixNano())
|
||||||
|
var lastDumpNS atomic.Int64
|
||||||
|
var dumpRequest = make(chan string, 4)
|
||||||
|
|
||||||
|
// Coordinated shutdown.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
done := make(chan struct{})
|
||||||
|
closeDone := sync.OnceFunc(func() { close(done) })
|
||||||
|
|
||||||
|
// Reader: PTY -> stdout passthrough + emulator + bytes tee.
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer closeDone()
|
||||||
|
buf := make([]byte, readBufferBytes)
|
||||||
|
for {
|
||||||
|
n, rerr := child.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := buf[:n]
|
||||||
|
// Tee to host stdout so the user can see the TUI normally.
|
||||||
|
_, _ = os.Stdout.Write(chunk)
|
||||||
|
// Tee to bytes file for golden replay.
|
||||||
|
_, _ = bytesFile.Write(chunk)
|
||||||
|
// Feed the emulator.
|
||||||
|
if _, werr := em.Write(chunk); werr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\nspike: emulator.Write error: %v\r\n", werr)
|
||||||
|
}
|
||||||
|
lastWriteNS.Store(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
// EIO from the PTY master is the normal "child closed its
|
||||||
|
// side" signal on Linux; treat it like EOF.
|
||||||
|
if rerr != io.EOF && !errors.Is(rerr, syscall.EIO) {
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\nspike: pty read: %v\r\n", rerr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Writer: stdin -> PTY, watching for the dump hotkey.
|
||||||
|
if stdinPassthrough {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
n, rerr := os.Stdin.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := buf[:n]
|
||||||
|
if debugStdin {
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\nspike[debug-stdin]: %d bytes:", n)
|
||||||
|
for _, b := range chunk {
|
||||||
|
fmt.Fprintf(os.Stderr, " %02x", b)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\n")
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, len(chunk))
|
||||||
|
for _, b := range chunk {
|
||||||
|
if b == hotkey {
|
||||||
|
select {
|
||||||
|
case dumpRequest <- "hotkey":
|
||||||
|
default:
|
||||||
|
// channel full; user is mashing the hotkey,
|
||||||
|
// dumps are still coming
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
if len(out) > 0 {
|
||||||
|
if _, werr := child.Write(out); werr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIGWINCH propagation.
|
||||||
|
if followHost {
|
||||||
|
winch := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(winch, syscall.SIGWINCH)
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer signal.Stop(winch)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-winch:
|
||||||
|
if c, r, ok := hostSize(); ok {
|
||||||
|
_ = child.Resize(c, r)
|
||||||
|
_ = em.Resize(c, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle ticker: enqueue an "idle" dump request when crossing the threshold.
|
||||||
|
if idleMS > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
tick := time.NewTicker(time.Duration(idleMS) * time.Millisecond / 4)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
lw := lastWriteNS.Load()
|
||||||
|
ld := lastDumpNS.Load()
|
||||||
|
if now-lw >= int64(idleMS)*int64(time.Millisecond) && lw > ld {
|
||||||
|
lastDumpNS.Store(now)
|
||||||
|
dumpRequest <- "idle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dump worker: serialises grid reads. PlainText is not cheap and we
|
||||||
|
// don't want overlapping calls.
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case reason := <-dumpRequest:
|
||||||
|
dumpGrid(em, reason, gridSink, gridIsStderr || gridToStderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the child to exit, then close everything down.
|
||||||
|
exitErr := child.Wait()
|
||||||
|
closeDone()
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Final dump for the record.
|
||||||
|
dumpGrid(em, "final", gridSink, gridIsStderr || gridToStderr)
|
||||||
|
|
||||||
|
if restoreState != nil {
|
||||||
|
_ = term.Restore(int(os.Stdin.Fd()), restoreState)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "spike: child exited (%v); bytes=%s grid=%s\r\n", exitErr, bytesPath, gridPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dumpGrid renders the emulator's active screen and writes it to sink.
|
||||||
|
//
|
||||||
|
// When sinkIsTTY is true the lines are terminated with CRLF so they render
|
||||||
|
// correctly even when stdin is in raw mode. Otherwise we use plain LF —
|
||||||
|
// log files don't want CRs.
|
||||||
|
func dumpGrid(em *vt.GhosttyEmulator, reason string, sink *os.File, sinkIsTTY bool) {
|
||||||
|
txt, err := em.PlainText()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\nspike: PlainText (%s): %v\r\n", reason, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cur, _ := em.Cursor()
|
||||||
|
scr, _ := em.ActiveScreen()
|
||||||
|
screenName := "primary"
|
||||||
|
if scr == vt.ScreenAlternate {
|
||||||
|
screenName = "alternate"
|
||||||
|
}
|
||||||
|
eol := "\n"
|
||||||
|
if sinkIsTTY {
|
||||||
|
eol = "\r\n"
|
||||||
|
}
|
||||||
|
sep := strings.Repeat("-", 78)
|
||||||
|
header := fmt.Sprintf("[grid dump: %s @ %s | screen=%s cursor=(%d,%d) visible=%v]",
|
||||||
|
reason, time.Now().Format(time.RFC3339Nano), screenName, cur.Col, cur.Row, cur.Visible)
|
||||||
|
fmt.Fprintf(sink, "%s%s%s%s%s%s", eol, sep, eol, header, eol, sep+eol)
|
||||||
|
for _, line := range strings.Split(txt, "\n") {
|
||||||
|
fmt.Fprintf(sink, "%s%s", line, eol)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(sink, "%s%s", sep, eol)
|
||||||
|
|
||||||
|
// If the sink is a file (not the host TTY), print a one-line breadcrumb
|
||||||
|
// to stderr so the user knows the hotkey fired — but only for explicit
|
||||||
|
// user-triggered dumps (hotkey), and only when the child is on the
|
||||||
|
// primary screen. Skipping idle/final dumps keeps the host terminal
|
||||||
|
// quiet during normal interactive use.
|
||||||
|
if !sinkIsTTY && reason == "hotkey" && scr != vt.ScreenAlternate {
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\nspike: dumped grid -> %s\r\n", sink.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostSize() (cols, rows uint16, ok bool) {
|
||||||
|
ws, err := cpty.GetsizeFull(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
if ws.Cols == 0 || ws.Rows == 0 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return ws.Cols, ws.Rows, true
|
||||||
|
}
|
||||||
82
cmd/spike/testdata/run-matrix.sh
vendored
Executable file
82
cmd/spike/testdata/run-matrix.sh
vendored
Executable file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Spike test matrix. Runs ./bin/spike against each target in sequence so a
|
||||||
|
# human can confirm by eye that the grid dump matches what the program would
|
||||||
|
# render in their own terminal.
|
||||||
|
#
|
||||||
|
# Run this from the project root:
|
||||||
|
# make spike
|
||||||
|
# ./cmd/spike/testdata/run-matrix.sh
|
||||||
|
#
|
||||||
|
# Each target's raw PTY recording lands in spike-<pid>.bytes and the final
|
||||||
|
# grid dump is appended to spike-report-<timestamp>.txt.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT=$(cd "$(dirname "$0")/../../.." && pwd)
|
||||||
|
BIN=${SPIKE_BIN:-$ROOT/bin/spike}
|
||||||
|
REPORT=$ROOT/spike-report-$(date +%Y%m%dT%H%M%S).txt
|
||||||
|
|
||||||
|
if [[ ! -x "$BIN" ]]; then
|
||||||
|
echo "spike binary not found at $BIN — run 'make spike' first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
note() { printf '\n=== %s ===\n' "$*" | tee -a "$REPORT"; }
|
||||||
|
have() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
run_case() {
|
||||||
|
local label=$1; shift
|
||||||
|
note "$label"
|
||||||
|
printf 'argv: %q ' "$@" | tee -a "$REPORT"
|
||||||
|
printf '\n'
|
||||||
|
printf 'Press Ctrl-] in the spike to dump the grid.\n'
|
||||||
|
printf 'Grid dumps go to spike-<pid>.grid.log (tail -f in another terminal to watch live).\n'
|
||||||
|
printf 'The child must exit cleanly to advance.\n'
|
||||||
|
printf 'Press Enter when ready to start this case (or s to skip)... '
|
||||||
|
read -r ans
|
||||||
|
if [[ "$ans" == "s" ]]; then
|
||||||
|
echo 'skipped' | tee -a "$REPORT"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# Tee spike's stderr so grid dumps are visible AND captured in the report.
|
||||||
|
# Without this, Ctrl-] dumps end up in $REPORT silently and look like
|
||||||
|
# the hotkey did nothing.
|
||||||
|
"$BIN" -- "$@" 2> >(tee -a "$REPORT" >&2)
|
||||||
|
echo "---" >> "$REPORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
note "spike test matrix — $(date -Is)"
|
||||||
|
"$BIN" -h 2>>"$REPORT" || true
|
||||||
|
|
||||||
|
# 1. Trivial stream-mode sanity check.
|
||||||
|
run_case "echo + sleep (stream sanity)" sh -c 'echo hello; sleep 1'
|
||||||
|
|
||||||
|
# 2. Interactive shell.
|
||||||
|
run_case "bash -i (prompt + history)" bash -i
|
||||||
|
|
||||||
|
# 3. Alt-screen line editor.
|
||||||
|
if have vim; then
|
||||||
|
run_case "vim README.md (alt-screen)" vim "$ROOT/SPEC.md"
|
||||||
|
else
|
||||||
|
echo "skip vim: not installed" | tee -a "$REPORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Alt-screen continuous redraw.
|
||||||
|
if have htop; then
|
||||||
|
run_case "htop (alt-screen, redraw)" htop
|
||||||
|
else
|
||||||
|
echo "skip htop: not installed" | tee -a "$REPORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5–7. Real targets — the actual point of the spike.
|
||||||
|
for agent in claude opencode codex; do
|
||||||
|
if have "$agent"; then
|
||||||
|
run_case "$agent (real target)" "$agent"
|
||||||
|
else
|
||||||
|
echo "skip $agent: not installed" | tee -a "$REPORT"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
note "matrix complete. report: $REPORT"
|
||||||
|
echo
|
||||||
|
echo "Next step: write up the per-target verdict in SPIKE-REPORT.md."
|
||||||
10
go.mod
Normal file
10
go.mod
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module github.com/harrybrwn/patterm
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/creack/pty v1.1.24
|
||||||
|
golang.org/x/term v0.43.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.44.0 // indirect
|
||||||
6
go.sum
Normal file
6
go.sum
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
724
internal/app/app.go
Normal file
724
internal/app/app.go
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
cpty "github.com/creack/pty"
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/mcp"
|
||||||
|
"github.com/harrybrwn/patterm/internal/policy"
|
||||||
|
"github.com/harrybrwn/patterm/internal/preset"
|
||||||
|
"github.com/harrybrwn/patterm/internal/scratchpad"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures a patterm run.
|
||||||
|
type Options struct {
|
||||||
|
ProjectDir string
|
||||||
|
ProjectKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyCtrlK byte = 0x0b
|
||||||
|
|
||||||
|
// Run is patterm's single-process entry point. SPEC §2: one Go process
|
||||||
|
// owns everything; no daemon, no detach, no socket-based reattachment.
|
||||||
|
func Run(ctx context.Context, opts Options) error {
|
||||||
|
if opts.ProjectDir == "" {
|
||||||
|
return errors.New("app: ProjectDir required")
|
||||||
|
}
|
||||||
|
|
||||||
|
presets, err := preset.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("app: load presets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pol, err := policy.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("app: load policy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the per-project scratchpad dir exists so MCP and the UI
|
||||||
|
// can read/write into it. SPEC §3.
|
||||||
|
pads, err := scratchpad.Open(opts.ProjectKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("app: scratchpad init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-process MCP server bound to the per-PID socket. Children that
|
||||||
|
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
|
||||||
|
// SPEC §10.
|
||||||
|
mcpSrv, err := mcp.Start()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("app: mcp start: %w", err)
|
||||||
|
}
|
||||||
|
defer mcpSrv.Close()
|
||||||
|
|
||||||
|
sess := NewSession(opts.ProjectDir, opts.ProjectKey)
|
||||||
|
defer sess.Shutdown()
|
||||||
|
|
||||||
|
cols, rows := hostSize()
|
||||||
|
|
||||||
|
layout := newTerminalLayout(cols, rows)
|
||||||
|
|
||||||
|
// Launcher handles preset → child translation, including MCP
|
||||||
|
// config injection for agent presets.
|
||||||
|
launcher := NewLauncher(sess, mcpSrv.Socket(), layout.childCols(), layout.childRows())
|
||||||
|
|
||||||
|
// Wire the tool host into MCP. Spawns through MCP use the host
|
||||||
|
// terminal's viewport grid for their initial PTY size; SIGWINCH paths
|
||||||
|
// resize them later.
|
||||||
|
host := newToolHost(sess, pads, launcher, presets, pol, layout.childCols(), layout.childRows())
|
||||||
|
mcpSrv.SetHost(host)
|
||||||
|
|
||||||
|
var restoreState *term.State
|
||||||
|
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
st, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("app: stdin raw: %w", err)
|
||||||
|
}
|
||||||
|
restoreState = st
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
st := &uiState{
|
||||||
|
sess: sess,
|
||||||
|
presets: presets,
|
||||||
|
launcher: launcher,
|
||||||
|
pads: pads,
|
||||||
|
hostCols: cols,
|
||||||
|
hostRows: rows,
|
||||||
|
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
|
||||||
|
}
|
||||||
|
host.attention = st
|
||||||
|
st.lastExit.Store(-1)
|
||||||
|
sess.Subscribe(st)
|
||||||
|
|
||||||
|
st.enterScreen()
|
||||||
|
st.renderEmptyState()
|
||||||
|
st.drawTabBar()
|
||||||
|
st.drawSidebar()
|
||||||
|
st.drawStatusLine()
|
||||||
|
|
||||||
|
// Set initial PTY grid for any future child. The child gets the
|
||||||
|
// computed main viewport, excluding tab bar, sidebar, and status.
|
||||||
|
sess.ResizeAll(layout.childCols(), layout.childRows())
|
||||||
|
launcher.SetSize(layout.childCols(), layout.childRows())
|
||||||
|
host.SetSize(layout.childCols(), layout.childRows())
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// SIGWINCH.
|
||||||
|
wg.Add(1)
|
||||||
|
winch := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(winch, syscall.SIGWINCH)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer signal.Stop(winch)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-winch:
|
||||||
|
c, r := hostSize()
|
||||||
|
if c == 0 || r == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st.dimsMu.Lock()
|
||||||
|
st.hostCols, st.hostRows = c, r
|
||||||
|
l := st.layoutLocked()
|
||||||
|
st.dimsMu.Unlock()
|
||||||
|
st.mu.Lock()
|
||||||
|
if st.renderer != nil {
|
||||||
|
st.renderer.SetLayout(l)
|
||||||
|
}
|
||||||
|
st.mu.Unlock()
|
||||||
|
sess.ResizeAll(l.childCols(), l.childRows())
|
||||||
|
launcher.SetSize(l.childCols(), l.childRows())
|
||||||
|
host.SetSize(l.childCols(), l.childRows())
|
||||||
|
st.clearScreen()
|
||||||
|
st.repaintFocused()
|
||||||
|
st.drawTabBar()
|
||||||
|
st.drawSidebar()
|
||||||
|
st.drawStatusLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// External termination: SPEC §2 step 4 (SIGTERM/SIGHUP → graceful exit).
|
||||||
|
wg.Add(1)
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer signal.Stop(sigCh)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case sig := <-sigCh:
|
||||||
|
st.dbgf("signal %s; tearing down", sig)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Stdin loop.
|
||||||
|
go func() {
|
||||||
|
if err := st.stdinLoop(); err != nil {
|
||||||
|
st.dbgf("stdinLoop: %v", err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
wg.Wait()
|
||||||
|
st.leaveScreen()
|
||||||
|
|
||||||
|
if restoreState != nil {
|
||||||
|
_ = term.Restore(int(os.Stdin.Fd()), restoreState)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.lastExit.Load() >= 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "patterm: last child exited (%d).\n", st.lastExit.Load())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiState is the shared state between the SIGWINCH loop, the stdin
|
||||||
|
// loop, and the session listener callbacks.
|
||||||
|
type uiState struct {
|
||||||
|
sess *Session
|
||||||
|
presets preset.Set
|
||||||
|
launcher *Launcher
|
||||||
|
pads *scratchpad.Store
|
||||||
|
|
||||||
|
outMu sync.Mutex
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
palette *paletteState
|
||||||
|
focusedID string
|
||||||
|
focusedName string
|
||||||
|
// renderer confines focused-child live output to the main viewport.
|
||||||
|
// A fresh renderer is allocated per focused child so partial-escape
|
||||||
|
// state cannot bleed between panes.
|
||||||
|
renderer *viewportRenderer
|
||||||
|
// passthrough: when true, the next keystroke is forwarded to the
|
||||||
|
// focused PTY untouched (SPEC §4 Ctrl-K Ctrl-K).
|
||||||
|
passthroughArmed bool
|
||||||
|
|
||||||
|
// attention is the latest request_human_attention surfaced via MCP;
|
||||||
|
// rendered in the status line until cleared.
|
||||||
|
attentionText string
|
||||||
|
attentionAt string
|
||||||
|
|
||||||
|
dimsMu sync.Mutex
|
||||||
|
hostCols, hostRows uint16
|
||||||
|
stdinTTY bool
|
||||||
|
|
||||||
|
lastExit atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) dbgf(format string, args ...any) {
|
||||||
|
logf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyAttention is the request_human_attention sink (SPEC §7). We
|
||||||
|
// surface a one-line toast in the status row and remember the most
|
||||||
|
// recent ask so the status line keeps showing it. The sidebar-blink is
|
||||||
|
// deferred until the §4 chrome lands.
|
||||||
|
func (st *uiState) notifyAttention(childID, reason string) {
|
||||||
|
c := st.sess.FindChild(childID)
|
||||||
|
name := childID
|
||||||
|
if c != nil {
|
||||||
|
name = c.Name
|
||||||
|
}
|
||||||
|
st.mu.Lock()
|
||||||
|
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
|
||||||
|
st.attentionAt = childID
|
||||||
|
st.mu.Unlock()
|
||||||
|
st.drawStatusLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnChildSpawned auto-focuses the new child.
|
||||||
|
func (st *uiState) OnChildSpawned(c *Child) {
|
||||||
|
st.mu.Lock()
|
||||||
|
st.focusedID = c.ID
|
||||||
|
st.focusedName = c.Name
|
||||||
|
st.renderer = newViewportRenderer(st.layoutSnapshot())
|
||||||
|
if st.palette != nil {
|
||||||
|
st.palette.children = st.sess.Children()
|
||||||
|
st.palette.focused = st.focusedID
|
||||||
|
st.palette.rebuild()
|
||||||
|
st.renderPaletteLocked()
|
||||||
|
}
|
||||||
|
st.mu.Unlock()
|
||||||
|
st.moveToViewportOrigin()
|
||||||
|
st.drawTabBar()
|
||||||
|
st.drawSidebar()
|
||||||
|
st.drawStatusLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnChildExited drops focus and shows the empty state if it was the
|
||||||
|
// focused child.
|
||||||
|
func (st *uiState) OnChildExited(c *Child) {
|
||||||
|
st.lastExit.Store(int32(c.ExitCode()))
|
||||||
|
st.mu.Lock()
|
||||||
|
if c.ID == st.focusedID {
|
||||||
|
next := firstRunningTopLevel(st.sess.Children())
|
||||||
|
if next == nil {
|
||||||
|
st.focusedID = ""
|
||||||
|
st.focusedName = ""
|
||||||
|
st.renderEmptyStateLocked()
|
||||||
|
} else {
|
||||||
|
st.focusedID = next.ID
|
||||||
|
st.focusedName = next.Name
|
||||||
|
st.renderer = newViewportRenderer(st.layoutSnapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if st.palette != nil {
|
||||||
|
st.palette.children = st.sess.Children()
|
||||||
|
st.palette.focused = st.focusedID
|
||||||
|
st.palette.rebuild()
|
||||||
|
st.renderPaletteLocked()
|
||||||
|
}
|
||||||
|
st.mu.Unlock()
|
||||||
|
if st.focusedID != "" {
|
||||||
|
st.repaintFocused()
|
||||||
|
}
|
||||||
|
st.drawTabBar()
|
||||||
|
st.drawSidebar()
|
||||||
|
st.drawStatusLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnPTYOut writes live output for the focused child when the palette is
|
||||||
|
// not covering the screen. The viewport renderer shifts cursor movement
|
||||||
|
// into the main pane and rewrites destructive clears. Host autowrap is
|
||||||
|
// disabled only around the replay so long styled runs cannot wrap into
|
||||||
|
// the right rail.
|
||||||
|
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
|
||||||
|
st.mu.Lock()
|
||||||
|
focus := st.focusedID
|
||||||
|
palOpen := st.palette != nil
|
||||||
|
renderer := st.renderer
|
||||||
|
st.mu.Unlock()
|
||||||
|
if palOpen || focus != childID || renderer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := renderer.Render(chunk)
|
||||||
|
st.outMu.Lock()
|
||||||
|
_, _ = os.Stdout.Write([]byte("\x1b[?7l"))
|
||||||
|
_, _ = os.Stdout.Write(out)
|
||||||
|
_, _ = os.Stdout.Write([]byte("\x1b[?7h"))
|
||||||
|
st.outMu.Unlock()
|
||||||
|
st.drawTabBar()
|
||||||
|
st.drawSidebar()
|
||||||
|
st.drawStatusLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) enterScreen() {
|
||||||
|
st.outMu.Lock()
|
||||||
|
defer st.outMu.Unlock()
|
||||||
|
_, _ = os.Stdout.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) leaveScreen() {
|
||||||
|
st.outMu.Lock()
|
||||||
|
defer st.outMu.Unlock()
|
||||||
|
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[?1049l"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) clearScreen() {
|
||||||
|
st.outMu.Lock()
|
||||||
|
defer st.outMu.Unlock()
|
||||||
|
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) moveToViewportOrigin() {
|
||||||
|
layout := st.layoutSnapshot()
|
||||||
|
st.outMu.Lock()
|
||||||
|
defer st.outMu.Unlock()
|
||||||
|
fmt.Fprintf(os.Stdout, "\x1b[%d;%dH", int(layout.mainTop), int(layout.mainLeft))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) renderPaletteLocked() {
|
||||||
|
if st.palette == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.outMu.Lock()
|
||||||
|
defer st.outMu.Unlock()
|
||||||
|
cols, rows := st.hostSizeSnapshot()
|
||||||
|
st.palette.render(wrapWriter(os.Stdout), int(cols), int(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawStatusLine renders SPEC §4's bottom status line. Left side: input
|
||||||
|
// ownership toast ("orchestrator driving" / "you have control") and any
|
||||||
|
// attention ask. Right side: palette hint. The PTY child occupies
|
||||||
|
// host_rows-1 rows so this row is exclusively ours.
|
||||||
|
func (st *uiState) drawStatusLine() {
|
||||||
|
st.mu.Lock()
|
||||||
|
palOpen := st.palette != nil
|
||||||
|
focusID := st.focusedID
|
||||||
|
focusName := st.focusedName
|
||||||
|
attention := st.attentionText
|
||||||
|
attentionAt := st.attentionAt
|
||||||
|
st.mu.Unlock()
|
||||||
|
if palOpen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cols, rows := st.hostSizeSnapshot()
|
||||||
|
if cols == 0 || rows == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
owner := ""
|
||||||
|
if focusID != "" {
|
||||||
|
if c := st.sess.FindChild(focusID); c != nil {
|
||||||
|
switch c.Owner() {
|
||||||
|
case OwnerOrchestrator:
|
||||||
|
owner = "orchestrator driving"
|
||||||
|
case OwnerUser:
|
||||||
|
owner = "you have control"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
left := ""
|
||||||
|
if focusName != "" {
|
||||||
|
left = focusName
|
||||||
|
}
|
||||||
|
if owner != "" {
|
||||||
|
if left != "" {
|
||||||
|
left = left + " · " + owner
|
||||||
|
} else {
|
||||||
|
left = owner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attention != "" && attentionAt == focusID {
|
||||||
|
left = "[!] " + attention
|
||||||
|
}
|
||||||
|
right := "Ctrl-K · palette"
|
||||||
|
|
||||||
|
pad := int(cols) - len(left) - len(right)
|
||||||
|
if pad < 1 {
|
||||||
|
pad = 1
|
||||||
|
}
|
||||||
|
line := left + strings.Repeat(" ", pad) + right
|
||||||
|
if len(line) > int(cols) {
|
||||||
|
line = line[:int(cols)]
|
||||||
|
}
|
||||||
|
st.outMu.Lock()
|
||||||
|
defer st.outMu.Unlock()
|
||||||
|
// Save cursor, move to last row col 1, write, restore.
|
||||||
|
fmt.Fprintf(os.Stdout, "\x1b7\x1b[999;1H\x1b[2m\x1b[7m%s\x1b[0m\x1b8", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderEmptyState is the SPEC §4 blank-canvas hint. Drawn whenever no
|
||||||
|
// child is focused.
|
||||||
|
func (st *uiState) renderEmptyState() {
|
||||||
|
st.mu.Lock()
|
||||||
|
defer st.mu.Unlock()
|
||||||
|
st.renderEmptyStateLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) renderEmptyStateLocked() {
|
||||||
|
st.outMu.Lock()
|
||||||
|
defer st.outMu.Unlock()
|
||||||
|
layout := st.layoutSnapshot()
|
||||||
|
line := "Press Ctrl-K to spawn an agent or process"
|
||||||
|
row := int(layout.mainTop) + (int(layout.childRows()) / 2)
|
||||||
|
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
|
||||||
|
if row < int(layout.mainTop) {
|
||||||
|
row = int(layout.mainTop)
|
||||||
|
}
|
||||||
|
if col < int(layout.mainLeft) {
|
||||||
|
col = int(layout.mainLeft)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
|
||||||
|
st.dimsMu.Lock()
|
||||||
|
defer st.dimsMu.Unlock()
|
||||||
|
return st.hostCols, st.hostRows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) layoutSnapshot() terminalLayout {
|
||||||
|
st.dimsMu.Lock()
|
||||||
|
defer st.dimsMu.Unlock()
|
||||||
|
return st.layoutLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) layoutLocked() terminalLayout {
|
||||||
|
return newTerminalLayout(st.hostCols, st.hostRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) stdinLoop() error {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := os.Stdin.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
st.processStdin(buf[:n])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read error %w (n=%d)", err, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processStdin walks one read of stdin byte by byte. The palette
|
||||||
|
// intercepts everything when it's open. Otherwise Ctrl-K opens it and
|
||||||
|
// every other byte forwards to the focused PTY. The Ctrl-K Ctrl-K chord
|
||||||
|
// is SPEC §4's passthrough prefix: after the first Ctrl-K, if the very
|
||||||
|
// next byte is another Ctrl-K, both are sent to the PTY literally.
|
||||||
|
//
|
||||||
|
// NOTE on locking: a palette-close action (spawn / switch / kill /
|
||||||
|
// quit) may fire session listeners (OnChildSpawned, OnChildExited)
|
||||||
|
// synchronously. Those listeners need st.mu. We must NOT hold st.mu
|
||||||
|
// when calling closePalette — bytes after the action in the same chunk
|
||||||
|
// are dropped on the floor, which is the right behavior anyway (the
|
||||||
|
// user just decided the prior pane is gone).
|
||||||
|
func (st *uiState) processStdin(chunk []byte) {
|
||||||
|
st.mu.Lock()
|
||||||
|
|
||||||
|
forward := make([]byte, 0, len(chunk))
|
||||||
|
flushForward := func() {
|
||||||
|
if len(forward) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.focusedID != "" {
|
||||||
|
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
|
||||||
|
prev := c.Owner()
|
||||||
|
_ = c.InjectAsUser(forward)
|
||||||
|
if prev != OwnerUser {
|
||||||
|
go st.drawStatusLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forward = forward[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingAction *paletteAction
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(chunk) {
|
||||||
|
b := chunk[i]
|
||||||
|
|
||||||
|
// Passthrough armed: forward this byte literally regardless of
|
||||||
|
// what it is, then disarm.
|
||||||
|
if st.passthroughArmed {
|
||||||
|
forward = append(forward, b)
|
||||||
|
st.passthroughArmed = false
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Palette mode swallows all bytes.
|
||||||
|
if st.palette != nil {
|
||||||
|
var peek []byte
|
||||||
|
if i+1 < len(chunk) {
|
||||||
|
peek = chunk[i+1:]
|
||||||
|
}
|
||||||
|
action, done := st.palette.handleKey(b, peek)
|
||||||
|
if b == 0x1b && len(peek) >= 2 && peek[0] == '[' {
|
||||||
|
if peek[1] == 'A' || peek[1] == 'B' {
|
||||||
|
i += 3
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
a := action
|
||||||
|
pendingAction = &a
|
||||||
|
break
|
||||||
|
}
|
||||||
|
st.renderPaletteLocked()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl-K is the reserved app-level binding. Two cases:
|
||||||
|
// - Ctrl-K then anything except Ctrl-K → open palette.
|
||||||
|
// - Ctrl-K Ctrl-K → arm passthrough; the next byte goes raw.
|
||||||
|
if b == keyCtrlK {
|
||||||
|
// Peek at the next byte if we have it.
|
||||||
|
next := byte(0)
|
||||||
|
haveNext := i+1 < len(chunk)
|
||||||
|
if haveNext {
|
||||||
|
next = chunk[i+1]
|
||||||
|
}
|
||||||
|
if haveNext && next == keyCtrlK {
|
||||||
|
// Chord: forward both Ctrl-K bytes literally. (Some
|
||||||
|
// nested TUIs expect Ctrl-K itself.)
|
||||||
|
flushForward()
|
||||||
|
forward = append(forward, keyCtrlK, keyCtrlK)
|
||||||
|
flushForward()
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !haveNext {
|
||||||
|
// Could be the first byte of a chord — arm and wait.
|
||||||
|
st.passthroughArmed = true
|
||||||
|
// But we also want palette-open on a lone Ctrl-K. Resolve
|
||||||
|
// by treating "Ctrl-K at end of read" as palette open;
|
||||||
|
// any subsequent Ctrl-K in the next read still has the
|
||||||
|
// chord semantics because passthroughArmed got set first.
|
||||||
|
// To match the spec's reading, simpler model: lone Ctrl-K
|
||||||
|
// in this read opens the palette.
|
||||||
|
st.passthroughArmed = false
|
||||||
|
flushForward()
|
||||||
|
st.openPaletteLocked()
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Ctrl-K followed by something that's not Ctrl-K → palette open.
|
||||||
|
flushForward()
|
||||||
|
st.openPaletteLocked()
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
forward = append(forward, b)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
flushForward()
|
||||||
|
st.mu.Unlock()
|
||||||
|
|
||||||
|
if pendingAction != nil {
|
||||||
|
st.closePalette(*pendingAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) openPaletteLocked() {
|
||||||
|
st.palette = newPalette(st.sess.Children(), st.focusedID, st.presets)
|
||||||
|
st.renderPaletteLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// closePalette is invoked with st.mu UNLOCKED. The session-mutating
|
||||||
|
// actions below (spawn / kill) fire listeners that take st.mu, so
|
||||||
|
// holding it here would deadlock. Each helper this calls takes its own
|
||||||
|
// brief mu acquisitions as needed.
|
||||||
|
func (st *uiState) closePalette(action paletteAction) {
|
||||||
|
st.mu.Lock()
|
||||||
|
st.palette = nil
|
||||||
|
st.mu.Unlock()
|
||||||
|
st.clearScreen()
|
||||||
|
|
||||||
|
switch action.kind {
|
||||||
|
case "", "cancel":
|
||||||
|
st.repaintFocused()
|
||||||
|
st.drawTabBar()
|
||||||
|
st.drawSidebar()
|
||||||
|
st.drawStatusLine()
|
||||||
|
|
||||||
|
case "spawn-agent":
|
||||||
|
if action.preset == nil {
|
||||||
|
st.repaintFocused()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := st.layoutSnapshot()
|
||||||
|
st.launcher.SetSize(l.childCols(), l.childRows())
|
||||||
|
// LaunchAgent fires OnChildSpawned synchronously; it will draw
|
||||||
|
// chrome and set focus.
|
||||||
|
if _, err := st.launcher.LaunchAgent(action.preset, action.preset.Name, "", ""); err != nil {
|
||||||
|
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "spawn-process":
|
||||||
|
if action.preset == nil {
|
||||||
|
st.repaintFocused()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := st.layoutSnapshot()
|
||||||
|
st.launcher.SetSize(l.childCols(), l.childRows())
|
||||||
|
if _, err := st.launcher.LaunchProcess(action.preset, action.preset.Name); err != nil {
|
||||||
|
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "switch":
|
||||||
|
c := st.sess.FindChild(action.childID)
|
||||||
|
if c == nil || c.Status() != StatusRunning {
|
||||||
|
st.repaintFocused()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.mu.Lock()
|
||||||
|
st.focusedID = action.childID
|
||||||
|
st.focusedName = c.Name
|
||||||
|
st.renderer = newViewportRenderer(st.layoutSnapshot())
|
||||||
|
st.mu.Unlock()
|
||||||
|
st.repaintFocused()
|
||||||
|
st.drawTabBar()
|
||||||
|
st.drawSidebar()
|
||||||
|
st.drawStatusLine()
|
||||||
|
|
||||||
|
case "kill":
|
||||||
|
_ = st.sess.Kill(action.childID, syscall.SIGTERM)
|
||||||
|
st.repaintFocused()
|
||||||
|
st.drawTabBar()
|
||||||
|
st.drawSidebar()
|
||||||
|
st.drawStatusLine()
|
||||||
|
|
||||||
|
case "quit":
|
||||||
|
st.requestExit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flashError surfaces a spawn/etc. failure in the status line until the
|
||||||
|
// next attention update overwrites it. stderr is hidden under the alt
|
||||||
|
// screen so we can't rely on Fprintln(os.Stderr).
|
||||||
|
func (st *uiState) flashError(msg string) {
|
||||||
|
st.mu.Lock()
|
||||||
|
st.attentionText = msg
|
||||||
|
st.attentionAt = "" // shows on every focus until cleared
|
||||||
|
st.mu.Unlock()
|
||||||
|
st.renderEmptyState()
|
||||||
|
st.drawTabBar()
|
||||||
|
st.drawSidebar()
|
||||||
|
st.drawStatusLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
// repaintFocused redraws the current focused child's screen snapshot.
|
||||||
|
// Callers must NOT hold st.mu — repaintFocused takes it
|
||||||
|
// briefly itself.
|
||||||
|
func (st *uiState) repaintFocused() {
|
||||||
|
st.mu.Lock()
|
||||||
|
id := st.focusedID
|
||||||
|
st.mu.Unlock()
|
||||||
|
if id == "" {
|
||||||
|
st.renderEmptyState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, cursor, err := st.sess.SnapshotChild(id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := renderScreenSnapshot(text, cursor, st.layoutSnapshot())
|
||||||
|
st.outMu.Lock()
|
||||||
|
defer st.outMu.Unlock()
|
||||||
|
_, _ = os.Stdout.Write(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *uiState) requestExit() {
|
||||||
|
// Reuse SIGTERM-to-self as the cleanest way to unwind: the signal
|
||||||
|
// handler in Run() calls cancel() which exits the loop and runs
|
||||||
|
// Shutdown.
|
||||||
|
_ = syscall.Kill(os.Getpid(), syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostSize() (cols, rows uint16) {
|
||||||
|
ws, err := cpty.GetsizeFull(os.Stdin)
|
||||||
|
if err != nil || ws.Cols == 0 || ws.Rows == 0 {
|
||||||
|
return 120, 40
|
||||||
|
}
|
||||||
|
return ws.Cols, ws.Rows
|
||||||
|
}
|
||||||
235
internal/app/child.go
Normal file
235
internal/app/child.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pkgpty "github.com/harrybrwn/patterm/internal/pty"
|
||||||
|
"github.com/harrybrwn/patterm/internal/vt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChildStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusRunning ChildStatus = "running"
|
||||||
|
StatusExited ChildStatus = "exited"
|
||||||
|
StatusErrored ChildStatus = "errored"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChildKind matches the two preset flavours in SPEC §10.
|
||||||
|
type ChildKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindAgent ChildKind = "agent"
|
||||||
|
KindProcess ChildKind = "process"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Owner reflects the SPEC §6 input-ownership flag.
|
||||||
|
type Owner string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OwnerUser Owner = "user"
|
||||||
|
OwnerOrchestrator Owner = "orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Child is one PTY-backed process plus its emulator. The same struct
|
||||||
|
// represents both agent presets (with MCP) and process presets (raw).
|
||||||
|
type Child struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Argv []string
|
||||||
|
Kind ChildKind
|
||||||
|
ParentID string // empty for top-level sessions
|
||||||
|
|
||||||
|
// Identity is the per-spawn token the mcp-stdio proxy uses to
|
||||||
|
// identify itself when calling tools. Empty for process presets.
|
||||||
|
Identity string
|
||||||
|
|
||||||
|
pty *pkgpty.PTY
|
||||||
|
em *vt.GhosttyEmulator
|
||||||
|
|
||||||
|
status atomic.Pointer[ChildStatus]
|
||||||
|
exitCode atomic.Int32
|
||||||
|
|
||||||
|
owner atomic.Pointer[Owner]
|
||||||
|
|
||||||
|
// lastWrite is the wall time of the most recent PTY-master write.
|
||||||
|
// SPEC §11 idle heuristic: a pane is idle once nothing has been
|
||||||
|
// written for the preset's threshold (default 1s).
|
||||||
|
lastWriteNS atomic.Int64
|
||||||
|
|
||||||
|
// ringMu guards ring. The ring buffer carries the last `ringCap`
|
||||||
|
// bytes the PTY produced, used by SPEC §7 read_output stream mode.
|
||||||
|
ringMu sync.Mutex
|
||||||
|
ring []byte
|
||||||
|
ringStart int64 // absolute offset of ring[0]
|
||||||
|
ringWrites int64 // cumulative bytes written
|
||||||
|
}
|
||||||
|
|
||||||
|
const ringCap = 1 << 20 // 1 MiB per SPEC §5
|
||||||
|
|
||||||
|
func newChild(id, name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return nil, errors.New("child: empty argv")
|
||||||
|
}
|
||||||
|
em, err := vt.NewGhosttyEmulator(cols, rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("child %s emulator: %w", id, err)
|
||||||
|
}
|
||||||
|
p, err := pkgpty.Start(argv, env, cols, rows)
|
||||||
|
if err != nil {
|
||||||
|
em.Close()
|
||||||
|
return nil, fmt.Errorf("child %s pty: %w", id, err)
|
||||||
|
}
|
||||||
|
c := &Child{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Argv: argv,
|
||||||
|
Kind: kind,
|
||||||
|
ParentID: parentID,
|
||||||
|
pty: p,
|
||||||
|
em: em,
|
||||||
|
ring: make([]byte, 0, ringCap),
|
||||||
|
}
|
||||||
|
st := StatusRunning
|
||||||
|
c.status.Store(&st)
|
||||||
|
c.exitCode.Store(-1)
|
||||||
|
// Agents spawned by an orchestrator default to orchestrator-owned;
|
||||||
|
// everything else (top-level, processes) defaults to user. SPEC §6.
|
||||||
|
def := OwnerUser
|
||||||
|
if kind == KindAgent && parentID != "" {
|
||||||
|
def = OwnerOrchestrator
|
||||||
|
}
|
||||||
|
c.owner.Store(&def)
|
||||||
|
|
||||||
|
if kind == KindAgent {
|
||||||
|
c.Identity = mintIdentity()
|
||||||
|
}
|
||||||
|
|
||||||
|
em.OnWritePTY(func(b []byte) {
|
||||||
|
_, _ = p.Write(b)
|
||||||
|
})
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Child) Status() ChildStatus {
|
||||||
|
st := c.status.Load()
|
||||||
|
if st == nil {
|
||||||
|
return StatusRunning
|
||||||
|
}
|
||||||
|
return *st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Child) ExitCode() int { return int(c.exitCode.Load()) }
|
||||||
|
|
||||||
|
func (c *Child) PID() int { return c.pty.Pid() }
|
||||||
|
|
||||||
|
func (c *Child) Owner() Owner {
|
||||||
|
o := c.owner.Load()
|
||||||
|
if o == nil {
|
||||||
|
return OwnerUser
|
||||||
|
}
|
||||||
|
return *o
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Child) SetOwner(o Owner) { c.owner.Store(&o) }
|
||||||
|
|
||||||
|
// IdleMS returns how many milliseconds since the last PTY write.
|
||||||
|
// 0 means "no writes yet". SPEC §11.
|
||||||
|
func (c *Child) IdleMS() int64 {
|
||||||
|
last := c.lastWriteNS.Load()
|
||||||
|
if last == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Child) recordWrite(chunk []byte) {
|
||||||
|
c.lastWriteNS.Store(time.Now().UnixNano())
|
||||||
|
c.ringMu.Lock()
|
||||||
|
defer c.ringMu.Unlock()
|
||||||
|
c.ring = append(c.ring, chunk...)
|
||||||
|
c.ringWrites += int64(len(chunk))
|
||||||
|
if len(c.ring) > ringCap {
|
||||||
|
drop := len(c.ring) - ringCap
|
||||||
|
c.ring = c.ring[drop:]
|
||||||
|
c.ringStart += int64(drop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamRead returns ring bytes from `since` to the current write head,
|
||||||
|
// plus the new offset. Offsets are absolute (cumulative bytes ever
|
||||||
|
// written). If `since` is before the ring start, the caller missed
|
||||||
|
// data; we return what we have and the new offset.
|
||||||
|
func (c *Child) StreamRead(since int64) ([]byte, int64) {
|
||||||
|
c.ringMu.Lock()
|
||||||
|
defer c.ringMu.Unlock()
|
||||||
|
if since < c.ringStart {
|
||||||
|
since = c.ringStart
|
||||||
|
}
|
||||||
|
end := c.ringStart + int64(len(c.ring))
|
||||||
|
if since >= end {
|
||||||
|
return nil, end
|
||||||
|
}
|
||||||
|
start := int(since - c.ringStart)
|
||||||
|
out := make([]byte, end-since)
|
||||||
|
copy(out, c.ring[start:])
|
||||||
|
return out, end
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Child) signal(sig syscall.Signal) error {
|
||||||
|
pid := c.pty.Pid()
|
||||||
|
if pid <= 0 {
|
||||||
|
return errors.New("child has no pid")
|
||||||
|
}
|
||||||
|
if err := syscall.Kill(-pid, sig); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return syscall.Kill(pid, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Child) markExited(err error) {
|
||||||
|
exitCode := int32(0)
|
||||||
|
st := StatusExited
|
||||||
|
if err != nil {
|
||||||
|
var ee *exec.ExitError
|
||||||
|
if errors.As(err, &ee) {
|
||||||
|
exitCode = int32(ee.ExitCode())
|
||||||
|
} else {
|
||||||
|
exitCode = -1
|
||||||
|
st = StatusErrored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.exitCode.Store(exitCode)
|
||||||
|
c.status.Store(&st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectAsUser is the path the human takes when typing in the focused
|
||||||
|
// pane. SPEC §6: the user's first keystroke flips ownership.
|
||||||
|
func (c *Child) InjectAsUser(b []byte) error {
|
||||||
|
c.SetOwner(OwnerUser)
|
||||||
|
_, err := c.pty.Write(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectAsOrchestrator is the path send_message_to / report_to_parent /
|
||||||
|
// initial_prompt / timer_wait writes take. Ownership flips back to
|
||||||
|
// orchestrator. SPEC §6.
|
||||||
|
func (c *Child) InjectAsOrchestrator(b []byte) error {
|
||||||
|
c.SetOwner(OwnerOrchestrator)
|
||||||
|
_, err := c.pty.Write(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintIdentity() string {
|
||||||
|
var buf [12]byte
|
||||||
|
_, _ = rand.Read(buf[:])
|
||||||
|
return hex.EncodeToString(buf[:])
|
||||||
|
}
|
||||||
267
internal/app/cursorshift.go
Normal file
267
internal/app/cursorshift.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cursorShifter rewrites cursor-positioning ANSI escapes in a PTY byte
|
||||||
|
// stream so the child's "row 1" becomes the host's "row 1+offset".
|
||||||
|
// This lets patterm reserve top rows for chrome (SPEC §4 tab bar)
|
||||||
|
// while keeping the child unaware.
|
||||||
|
//
|
||||||
|
// Sequences rewritten:
|
||||||
|
// - CSI H, CSI <r> H, CSI <r>;<c> H — CUP
|
||||||
|
// - CSI <r>;<c> f — HVP
|
||||||
|
// - CSI <n> d — VPA (line position absolute)
|
||||||
|
// - CSI <t>;<b> r — DECSTBM (scrolling region)
|
||||||
|
//
|
||||||
|
// Other sequences (SGR, mode set, OSC titles, DCS, alt-screen toggles)
|
||||||
|
// are forwarded byte-for-byte. The parser tracks OSC/DCS/SOS/PM/APC
|
||||||
|
// state so byte sequences inside those wrappers are NOT misread as
|
||||||
|
// CSI commands.
|
||||||
|
type cursorShifter struct {
|
||||||
|
rowOffset int
|
||||||
|
|
||||||
|
state shifterState
|
||||||
|
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
|
||||||
|
csiPrefix []byte // private prefix bytes (?, >, =) after CSI
|
||||||
|
pending strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
type shifterState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
stNormal shifterState = iota
|
||||||
|
stEsc
|
||||||
|
stCSI
|
||||||
|
stCSIPrefix // CSI <private-prefix>... — private prefix means we DON'T rewrite
|
||||||
|
stOSC
|
||||||
|
stOSCEsc // we saw ESC inside OSC; expect '\' to close ST
|
||||||
|
stDCS
|
||||||
|
stDCSEsc
|
||||||
|
stSOSPMAPC // SOS/PM/APC body — terminator is ESC \
|
||||||
|
stSOSPMAPCEsc
|
||||||
|
)
|
||||||
|
|
||||||
|
func newCursorShifter(rowOffset int) *cursorShifter {
|
||||||
|
return &cursorShifter{rowOffset: rowOffset}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *cursorShifter) SetRowOffset(off int) {
|
||||||
|
cs.rowOffset = off
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift consumes a chunk of PTY-master bytes, applies row offsets to
|
||||||
|
// any complete CUP/HVP/VPA/DECSTBM sequences, and returns the rewritten
|
||||||
|
// bytes. Partial sequences are buffered across calls so a CSI that
|
||||||
|
// straddles two PTY reads still gets rewritten.
|
||||||
|
func (cs *cursorShifter) Shift(in []byte) []byte {
|
||||||
|
cs.pending.Reset()
|
||||||
|
for _, b := range in {
|
||||||
|
cs.feed(b)
|
||||||
|
}
|
||||||
|
out := cs.pending.String()
|
||||||
|
return []byte(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *cursorShifter) feed(b byte) {
|
||||||
|
switch cs.state {
|
||||||
|
case stNormal:
|
||||||
|
if b == 0x1b {
|
||||||
|
cs.state = stEsc
|
||||||
|
cs.buf = cs.buf[:0]
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cs.pending.WriteByte(b)
|
||||||
|
|
||||||
|
case stEsc:
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
switch b {
|
||||||
|
case '[':
|
||||||
|
cs.state = stCSI
|
||||||
|
cs.csiPrefix = cs.csiPrefix[:0]
|
||||||
|
case ']':
|
||||||
|
cs.state = stOSC
|
||||||
|
case 'P':
|
||||||
|
cs.state = stDCS
|
||||||
|
case 'X', '^', '_':
|
||||||
|
cs.state = stSOSPMAPC
|
||||||
|
default:
|
||||||
|
// Two-byte ESC sequence: ESC <something>. Forward as-is.
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
cs.state = stNormal
|
||||||
|
cs.buf = cs.buf[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
case stCSI:
|
||||||
|
// First non-param byte after CSI might be a private prefix
|
||||||
|
// (?, >, =, etc., 0x3c..0x3f). If so, switch to CSIPrefix and
|
||||||
|
// don't rewrite this sequence.
|
||||||
|
if len(cs.csiPrefix) == 0 && len(cs.buf) == 2 && b >= 0x3c && b <= 0x3f {
|
||||||
|
cs.csiPrefix = append(cs.csiPrefix, b)
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
cs.state = stCSIPrefix
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
if isCSIFinal(b) {
|
||||||
|
cs.emitCSI()
|
||||||
|
cs.state = stNormal
|
||||||
|
cs.buf = cs.buf[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
case stCSIPrefix:
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
if isCSIFinal(b) {
|
||||||
|
// Private CSI; forward unchanged.
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
cs.state = stNormal
|
||||||
|
cs.buf = cs.buf[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
case stOSC:
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
switch b {
|
||||||
|
case 0x07: // BEL
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
cs.state = stNormal
|
||||||
|
cs.buf = cs.buf[:0]
|
||||||
|
case 0x1b:
|
||||||
|
cs.state = stOSCEsc
|
||||||
|
}
|
||||||
|
|
||||||
|
case stOSCEsc:
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
// ESC \ terminates ST.
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
cs.state = stNormal
|
||||||
|
cs.buf = cs.buf[:0]
|
||||||
|
|
||||||
|
case stDCS:
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
if b == 0x1b {
|
||||||
|
cs.state = stDCSEsc
|
||||||
|
}
|
||||||
|
|
||||||
|
case stDCSEsc:
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
cs.state = stNormal
|
||||||
|
cs.buf = cs.buf[:0]
|
||||||
|
|
||||||
|
case stSOSPMAPC:
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
if b == 0x1b {
|
||||||
|
cs.state = stSOSPMAPCEsc
|
||||||
|
}
|
||||||
|
|
||||||
|
case stSOSPMAPCEsc:
|
||||||
|
cs.buf = append(cs.buf, b)
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
cs.state = stNormal
|
||||||
|
cs.buf = cs.buf[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitCSI writes the buffered CSI sequence to pending, rewriting row
|
||||||
|
// coordinates for CUP/HVP/VPA/DECSTBM.
|
||||||
|
func (cs *cursorShifter) emitCSI() {
|
||||||
|
// cs.buf is ESC [ <params...> <final>. Slice out params + final.
|
||||||
|
if len(cs.buf) < 3 {
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
final := cs.buf[len(cs.buf)-1]
|
||||||
|
paramsRaw := cs.buf[2 : len(cs.buf)-1]
|
||||||
|
// Intermediate bytes can appear before the final (rare). Skip
|
||||||
|
// rewriting if any are present.
|
||||||
|
for _, b := range paramsRaw {
|
||||||
|
if b >= 0x20 && b <= 0x2f {
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch final {
|
||||||
|
case 'H', 'f':
|
||||||
|
// CUP/HVP: r;c (both default 1).
|
||||||
|
r, c, ok := parseTwoParams(paramsRaw)
|
||||||
|
if !ok {
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r += cs.rowOffset
|
||||||
|
cs.pending.WriteString("\x1b[")
|
||||||
|
cs.pending.WriteString(strconv.Itoa(r))
|
||||||
|
cs.pending.WriteByte(';')
|
||||||
|
cs.pending.WriteString(strconv.Itoa(c))
|
||||||
|
cs.pending.WriteByte(final)
|
||||||
|
case 'd':
|
||||||
|
// VPA: row.
|
||||||
|
r, ok := parseOneParam(paramsRaw, 1)
|
||||||
|
if !ok {
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r += cs.rowOffset
|
||||||
|
cs.pending.WriteString("\x1b[")
|
||||||
|
cs.pending.WriteString(strconv.Itoa(r))
|
||||||
|
cs.pending.WriteByte(final)
|
||||||
|
case 'r':
|
||||||
|
// DECSTBM: top;bot. Empty resets to full region; we still
|
||||||
|
// shift to keep the chrome row reserved.
|
||||||
|
top, bot, ok := parseTwoParams(paramsRaw)
|
||||||
|
if !ok {
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
top += cs.rowOffset
|
||||||
|
bot += cs.rowOffset
|
||||||
|
cs.pending.WriteString("\x1b[")
|
||||||
|
cs.pending.WriteString(strconv.Itoa(top))
|
||||||
|
cs.pending.WriteByte(';')
|
||||||
|
cs.pending.WriteString(strconv.Itoa(bot))
|
||||||
|
cs.pending.WriteByte(final)
|
||||||
|
default:
|
||||||
|
cs.pending.Write(cs.buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCSIFinal(b byte) bool { return b >= 0x40 && b <= 0x7e }
|
||||||
|
|
||||||
|
func parseTwoParams(raw []byte) (int, int, bool) {
|
||||||
|
parts := strings.Split(string(raw), ";")
|
||||||
|
if len(parts) > 2 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
a := 1
|
||||||
|
b := 1
|
||||||
|
if len(parts) >= 1 && parts[0] != "" {
|
||||||
|
n, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
a = n
|
||||||
|
}
|
||||||
|
if len(parts) >= 2 && parts[1] != "" {
|
||||||
|
n, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
b = n
|
||||||
|
}
|
||||||
|
return a, b, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOneParam(raw []byte, def int) (int, bool) {
|
||||||
|
s := string(raw)
|
||||||
|
if s == "" {
|
||||||
|
return def, true
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
77
internal/app/cursorshift_test.go
Normal file
77
internal/app/cursorshift_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCursorShifterCUP(t *testing.T) {
|
||||||
|
cs := newCursorShifter(1)
|
||||||
|
got := cs.Shift([]byte("\x1b[H"))
|
||||||
|
want := []byte("\x1b[2;1H")
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Fatalf("CUP home: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorShifterCUPRowCol(t *testing.T) {
|
||||||
|
cs := newCursorShifter(1)
|
||||||
|
got := cs.Shift([]byte("\x1b[10;5H"))
|
||||||
|
if string(got) != "\x1b[11;5H" {
|
||||||
|
t.Fatalf("CUP 10;5: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorShifterVPA(t *testing.T) {
|
||||||
|
cs := newCursorShifter(1)
|
||||||
|
got := cs.Shift([]byte("\x1b[7d"))
|
||||||
|
if string(got) != "\x1b[8d" {
|
||||||
|
t.Fatalf("VPA 7: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorShifterDECSTBM(t *testing.T) {
|
||||||
|
cs := newCursorShifter(1)
|
||||||
|
got := cs.Shift([]byte("\x1b[2;20r"))
|
||||||
|
if string(got) != "\x1b[3;21r" {
|
||||||
|
t.Fatalf("DECSTBM: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
|
||||||
|
cs := newCursorShifter(1)
|
||||||
|
// Alt-screen toggle — private CSI.
|
||||||
|
got := cs.Shift([]byte("\x1b[?1049h"))
|
||||||
|
if string(got) != "\x1b[?1049h" {
|
||||||
|
t.Fatalf("alt-screen: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorShifterSGRPassthrough(t *testing.T) {
|
||||||
|
cs := newCursorShifter(1)
|
||||||
|
got := cs.Shift([]byte("\x1b[1;31mhello\x1b[0m"))
|
||||||
|
if string(got) != "\x1b[1;31mhello\x1b[0m" {
|
||||||
|
t.Fatalf("SGR: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorShifterStraddleChunks(t *testing.T) {
|
||||||
|
cs := newCursorShifter(1)
|
||||||
|
a := cs.Shift([]byte("\x1b["))
|
||||||
|
b := cs.Shift([]byte("5;3H"))
|
||||||
|
got := string(a) + string(b)
|
||||||
|
if got != "\x1b[6;3H" {
|
||||||
|
t.Fatalf("straddle: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorShifterOSCNotRewritten(t *testing.T) {
|
||||||
|
cs := newCursorShifter(1)
|
||||||
|
// OSC body containing what looks like a CSI cursor move — should
|
||||||
|
// NOT be rewritten.
|
||||||
|
in := []byte("\x1b]0;\x1b[5;3Htitle\x07")
|
||||||
|
got := cs.Shift(in)
|
||||||
|
if string(got) != string(in) {
|
||||||
|
t.Fatalf("OSC: got %q want %q", got, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
335
internal/app/host.go
Normal file
335
internal/app/host.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/mcp"
|
||||||
|
"github.com/harrybrwn/patterm/internal/policy"
|
||||||
|
"github.com/harrybrwn/patterm/internal/preset"
|
||||||
|
"github.com/harrybrwn/patterm/internal/scratchpad"
|
||||||
|
)
|
||||||
|
|
||||||
|
// attentionSink is implemented by uiState to surface
|
||||||
|
// request_human_attention notifications.
|
||||||
|
type attentionSink interface {
|
||||||
|
notifyAttention(childID, reason string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolHost adapts the running session + scratchpad store to the MCP
|
||||||
|
// ToolHost interface. SPEC §7 tools route through here.
|
||||||
|
type toolHost struct {
|
||||||
|
sess *Session
|
||||||
|
pads *scratchpad.Store
|
||||||
|
launcher *Launcher
|
||||||
|
presets preset.Set
|
||||||
|
policy *policy.Policy
|
||||||
|
sizeMu sync.Mutex
|
||||||
|
defaultRow uint16
|
||||||
|
defaultCol uint16
|
||||||
|
|
||||||
|
attention attentionSink
|
||||||
|
|
||||||
|
// timersMu guards timers.
|
||||||
|
timersMu sync.Mutex
|
||||||
|
nextTimer int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *toolHost) SetSize(cols, rows uint16) {
|
||||||
|
h.sizeMu.Lock()
|
||||||
|
defer h.sizeMu.Unlock()
|
||||||
|
h.defaultCol = cols
|
||||||
|
h.defaultRow = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *toolHost) size() (uint16, uint16) {
|
||||||
|
h.sizeMu.Lock()
|
||||||
|
defer h.sizeMu.Unlock()
|
||||||
|
return h.defaultCol, h.defaultRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, pol *policy.Policy, cols, rows uint16) *toolHost {
|
||||||
|
return &toolHost{
|
||||||
|
sess: sess,
|
||||||
|
pads: pads,
|
||||||
|
launcher: launcher,
|
||||||
|
presets: presets,
|
||||||
|
policy: pol,
|
||||||
|
defaultCol: cols,
|
||||||
|
defaultRow: rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyCheck — SPEC §9 hook. Lets an orchestrator ask whether a
|
||||||
|
// prompt-looking string is safe to auto-answer.
|
||||||
|
func (h *toolHost) PolicyCheck(prompt string) string {
|
||||||
|
if h.policy == nil {
|
||||||
|
return string(policy.Unknown)
|
||||||
|
}
|
||||||
|
return string(h.policy.Should(prompt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children — SPEC §7 list_children. The idle_ms field gives the
|
||||||
|
// orchestrator the SPEC §11 done-signal without needing to poll bytes.
|
||||||
|
func (h *toolHost) Children() []mcp.ChildInfo {
|
||||||
|
cs := h.sess.Children()
|
||||||
|
out := make([]mcp.ChildInfo, 0, len(cs))
|
||||||
|
for _, c := range cs {
|
||||||
|
out = append(out, mcp.ChildInfo{
|
||||||
|
ID: c.ID,
|
||||||
|
Name: c.Name,
|
||||||
|
Type: string(c.Kind),
|
||||||
|
Status: string(c.Status()),
|
||||||
|
ExitCode: c.ExitCode(),
|
||||||
|
IdleMS: c.IdleMS(),
|
||||||
|
ParentID: c.ParentID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *toolHost) Spawn(callerID, name string, argv []string, shell bool) (mcp.ChildInfo, error) {
|
||||||
|
if shell && len(argv) > 0 {
|
||||||
|
argv = []string{"sh", "-lc", strings.Join(argv, " ")}
|
||||||
|
}
|
||||||
|
parent := callerID
|
||||||
|
cols, rows := h.size()
|
||||||
|
c, err := h.sess.Spawn(name, KindProcess, argv, nil, cols, rows, parent)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ChildInfo{}, err
|
||||||
|
}
|
||||||
|
return mcp.ChildInfo{
|
||||||
|
ID: c.ID,
|
||||||
|
Name: c.Name,
|
||||||
|
Type: string(c.Kind),
|
||||||
|
Status: string(c.Status()),
|
||||||
|
ParentID: c.ParentID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *toolHost) SpawnAgent(callerID, presetName, displayName, initialPrompt string) (mcp.ChildInfo, error) {
|
||||||
|
var p *preset.Preset
|
||||||
|
for _, ap := range h.presets.Agents {
|
||||||
|
if ap.Name == presetName {
|
||||||
|
p = ap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
return mcp.ChildInfo{}, fmt.Errorf("unknown agent preset %q", presetName)
|
||||||
|
}
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = presetName
|
||||||
|
}
|
||||||
|
c, err := h.launcher.LaunchAgent(p, displayName, initialPrompt, callerID)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ChildInfo{}, err
|
||||||
|
}
|
||||||
|
return mcp.ChildInfo{
|
||||||
|
ID: c.ID,
|
||||||
|
Name: c.Name,
|
||||||
|
Type: string(c.Kind),
|
||||||
|
Status: string(c.Status()),
|
||||||
|
ParentID: c.ParentID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadOutput — SPEC §7. Grid uses the emulator's PlainText; stream uses
|
||||||
|
// the per-child ring buffer. For grid reads on agents we apply the
|
||||||
|
// preset's chrome_trim_hints (SPEC §10) so banners/input-box noise
|
||||||
|
// doesn't pollute orchestrator parsing.
|
||||||
|
func (h *toolHost) ReadOutput(callerID, childID, mode string, sinceOffset int) (string, int, error) {
|
||||||
|
c := h.sess.FindChild(childID)
|
||||||
|
if c == nil {
|
||||||
|
return "", 0, fmt.Errorf("no such child %q", childID)
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case "grid":
|
||||||
|
txt, err := c.em.PlainText()
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
if c.Kind == KindAgent {
|
||||||
|
txt = applyChromeTrim(txt, h.chromeHintsFor(c.Name))
|
||||||
|
}
|
||||||
|
return txt, 0, nil
|
||||||
|
case "stream":
|
||||||
|
b, off := c.StreamRead(int64(sinceOffset))
|
||||||
|
return string(b), int(off), nil
|
||||||
|
default:
|
||||||
|
return "", 0, fmt.Errorf("unknown read_output mode %q", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *toolHost) chromeHintsFor(presetName string) []string {
|
||||||
|
for _, p := range h.presets.Agents {
|
||||||
|
if p.Name == presetName {
|
||||||
|
return p.ChromeTrimHints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyChromeTrim deletes every line that matches any of the given
|
||||||
|
// regexes. Compiled regexes are cached per call; the agent preset list
|
||||||
|
// is small enough that recompilation cost is negligible.
|
||||||
|
func applyChromeTrim(txt string, hints []string) string {
|
||||||
|
if len(hints) == 0 {
|
||||||
|
return txt
|
||||||
|
}
|
||||||
|
res := make([]*regexp.Regexp, 0, len(hints))
|
||||||
|
for _, h := range hints {
|
||||||
|
re, err := regexp.Compile(h)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res = append(res, re)
|
||||||
|
}
|
||||||
|
if len(res) == 0 {
|
||||||
|
return txt
|
||||||
|
}
|
||||||
|
out := make([]string, 0, 64)
|
||||||
|
for _, line := range strings.Split(txt, "\n") {
|
||||||
|
drop := false
|
||||||
|
for _, re := range res {
|
||||||
|
if re.MatchString(line) {
|
||||||
|
drop = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !drop {
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *toolHost) SendInput(callerID, childID string, payload []byte, appendNewline bool) error {
|
||||||
|
if appendNewline {
|
||||||
|
payload = append(payload, '\n')
|
||||||
|
}
|
||||||
|
c := h.sess.FindChild(childID)
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("no such child %q", childID)
|
||||||
|
}
|
||||||
|
if c.Status() != StatusRunning {
|
||||||
|
return fmt.Errorf("child %q is %s", childID, c.Status())
|
||||||
|
}
|
||||||
|
return c.InjectAsOrchestrator(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *toolHost) Kill(callerID, childID string, sig syscall.Signal) error {
|
||||||
|
return h.sess.Kill(childID, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageTo — SPEC §7 + §8. Injects "[orchestrator] <msg>\n" into
|
||||||
|
// the target's PTY.
|
||||||
|
func (h *toolHost) SendMessageTo(callerID, targetID, message string) error {
|
||||||
|
target := h.sess.FindChild(targetID)
|
||||||
|
if target == nil {
|
||||||
|
return fmt.Errorf("no such child %q", targetID)
|
||||||
|
}
|
||||||
|
line := "[orchestrator] " + message + "\n"
|
||||||
|
return target.InjectAsOrchestrator([]byte(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportToParent — SPEC §8. Injects "[sub-agent:<name>] <msg>\n" into
|
||||||
|
// the calling agent's parent pane.
|
||||||
|
func (h *toolHost) ReportToParent(callerID, message string) error {
|
||||||
|
caller := h.sess.FindChild(callerID)
|
||||||
|
if caller == nil {
|
||||||
|
return fmt.Errorf("caller %q not known to patterm", callerID)
|
||||||
|
}
|
||||||
|
if caller.ParentID == "" {
|
||||||
|
return fmt.Errorf("caller %q has no parent", callerID)
|
||||||
|
}
|
||||||
|
parent := h.sess.FindChild(caller.ParentID)
|
||||||
|
if parent == nil {
|
||||||
|
return fmt.Errorf("parent %q gone", caller.ParentID)
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf("[sub-agent:%s] %s\n", caller.Name, message)
|
||||||
|
return parent.InjectAsOrchestrator([]byte(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimerWait — SPEC §7. Returns immediately with a timer_id. After
|
||||||
|
// seconds elapse, injects "[system] Your timer [<label>] has completed.\n"
|
||||||
|
// into the calling agent's pane.
|
||||||
|
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
|
||||||
|
caller := h.sess.FindChild(callerID)
|
||||||
|
if caller == nil {
|
||||||
|
return "", fmt.Errorf("caller %q not known to patterm", callerID)
|
||||||
|
}
|
||||||
|
h.timersMu.Lock()
|
||||||
|
h.nextTimer++
|
||||||
|
id := fmt.Sprintf("t%d", h.nextTimer)
|
||||||
|
h.timersMu.Unlock()
|
||||||
|
if label == "" {
|
||||||
|
label = id
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
time.Sleep(time.Duration(seconds * float64(time.Second)))
|
||||||
|
if caller.Status() != StatusRunning {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf("[system] Your timer [%s] has completed.\n", label)
|
||||||
|
_ = caller.InjectAsOrchestrator([]byte(line))
|
||||||
|
}()
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForPattern — SPEC §7. Polls the child's plain-text grid at ~50ms
|
||||||
|
// until the regex matches or the timeout expires.
|
||||||
|
func (h *toolHost) WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (bool, string, error) {
|
||||||
|
c := h.sess.FindChild(childID)
|
||||||
|
if c == nil {
|
||||||
|
return false, "", fmt.Errorf("no such child %q", childID)
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", fmt.Errorf("regex: %w", err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
|
||||||
|
tick := time.NewTicker(50 * time.Millisecond)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
txt, err := c.em.PlainText()
|
||||||
|
if err == nil {
|
||||||
|
if m := re.FindString(txt); m != "" {
|
||||||
|
return true, m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
<-tick.C
|
||||||
|
if c.Status() != StatusRunning {
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *toolHost) RequestHumanAttention(callerID, childID, reason string) error {
|
||||||
|
if h.attention != nil {
|
||||||
|
h.attention.notifyAttention(childID, reason)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *toolHost) Scratchpads() *scratchpad.Store {
|
||||||
|
return h.pads
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCallerIdentity maps the per-spawn identity token back to a
|
||||||
|
// child ID so the tools above can use it as a parent pointer / inject
|
||||||
|
// target.
|
||||||
|
func (h *toolHost) ResolveCallerIdentity(identity string) string {
|
||||||
|
c := h.sess.FindChildByIdentity(identity)
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.ID
|
||||||
|
}
|
||||||
179
internal/app/launch.go
Normal file
179
internal/app/launch.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/preset"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Launcher knows how to turn a preset into a running child. Both the
|
||||||
|
// palette and the MCP spawn_agent tool route through here so MCP
|
||||||
|
// injection happens consistently. SPEC §10.
|
||||||
|
type Launcher struct {
|
||||||
|
sess *Session
|
||||||
|
mcpSocket string
|
||||||
|
bin string // path to this binary (for the mcp-stdio subcommand)
|
||||||
|
sizeMu sync.Mutex
|
||||||
|
cols, rows uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLauncher(sess *Session, mcpSocket string, cols, rows uint16) *Launcher {
|
||||||
|
bin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
bin = "patterm"
|
||||||
|
}
|
||||||
|
return &Launcher{sess: sess, mcpSocket: mcpSocket, bin: bin, cols: cols, rows: rows}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Launcher) SetSize(cols, rows uint16) {
|
||||||
|
l.sizeMu.Lock()
|
||||||
|
defer l.sizeMu.Unlock()
|
||||||
|
l.cols, l.rows = cols, rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Launcher) size() (uint16, uint16) {
|
||||||
|
l.sizeMu.Lock()
|
||||||
|
defer l.sizeMu.Unlock()
|
||||||
|
return l.cols, l.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaunchAgent spawns the agent preset, applies the preset's MCP
|
||||||
|
// injection, waits for the ready signal, and types initial_prompt into
|
||||||
|
// the PTY. SPEC §7 spawn_agent, §8 conversation protocol.
|
||||||
|
func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, parentID string) (*Child, error) {
|
||||||
|
if p.Kind != preset.KindAgent {
|
||||||
|
return nil, fmt.Errorf("launch: %q is not an agent preset", p.Name)
|
||||||
|
}
|
||||||
|
argv := append([]string(nil), p.Argv...)
|
||||||
|
env := l.sess.ChildEnv()
|
||||||
|
for k, v := range p.Env {
|
||||||
|
env = append(env, k+"="+v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a per-spawn MCP config file pointing at the mcp-stdio proxy
|
||||||
|
// with the new child's identity. We don't know the identity until
|
||||||
|
// we've created the child, but the child needs the env/argv at
|
||||||
|
// creation time — so we reserve the identity by pre-creating the
|
||||||
|
// MCP config with a placeholder, then patching it post-spawn.
|
||||||
|
identity, mcpConfigPath, err := l.writeMCPConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.MCPInjection != nil {
|
||||||
|
switch p.MCPInjection.Kind {
|
||||||
|
case "flag":
|
||||||
|
if p.MCPInjection.Flag == "" {
|
||||||
|
return nil, fmt.Errorf("preset %s: mcp_injection.flag required for kind=flag", p.Name)
|
||||||
|
}
|
||||||
|
argv = append(argv, p.MCPInjection.Flag, mcpConfigPath)
|
||||||
|
case "env_var":
|
||||||
|
if p.MCPInjection.Var == "" {
|
||||||
|
return nil, fmt.Errorf("preset %s: mcp_injection.var required for kind=env_var", p.Name)
|
||||||
|
}
|
||||||
|
env = append(env, p.MCPInjection.Var+"="+mcpConfigPath)
|
||||||
|
case "config_file":
|
||||||
|
// SPEC §10 mentions merging into an external config file. We
|
||||||
|
// expose the config_path via an env var the user can read
|
||||||
|
// at preset-creation time; full merge is deferred.
|
||||||
|
env = append(env, "PATTERM_MCP_CONFIG="+mcpConfigPath)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("preset %s: unknown mcp_injection.kind %q", p.Name, p.MCPInjection.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn with the chosen identity.
|
||||||
|
cols, rows := l.size()
|
||||||
|
c, err := l.sess.spawnWithIdentity(displayName, KindAgent, argv, env, cols, rows, parentID, identity)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(mcpConfigPath)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the preset's ready signal, then type the initial prompt.
|
||||||
|
idle := time.Duration(1000) * time.Millisecond
|
||||||
|
if p.ReadySignal != nil && p.ReadySignal.IdleMS > 0 {
|
||||||
|
idle = time.Duration(p.ReadySignal.IdleMS) * time.Millisecond
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
waitForIdle(c, idle, 30*time.Second)
|
||||||
|
if initialPrompt == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = c.InjectAsOrchestrator([]byte(initialPrompt + "\n"))
|
||||||
|
}()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaunchProcess spawns a process preset. No MCP injection; just argv.
|
||||||
|
func (l *Launcher) LaunchProcess(p *preset.Preset, displayName string) (*Child, error) {
|
||||||
|
if p.Kind != preset.KindProcess {
|
||||||
|
return nil, fmt.Errorf("launch: %q is not a process preset", p.Name)
|
||||||
|
}
|
||||||
|
env := l.sess.ChildEnv()
|
||||||
|
for k, v := range p.Env {
|
||||||
|
env = append(env, k+"="+v)
|
||||||
|
}
|
||||||
|
cols, rows := l.size()
|
||||||
|
return l.sess.Spawn(displayName, KindProcess, p.ResolvedArgv(), env, cols, rows, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Launcher) writeMCPConfig() (identity, path string, err error) {
|
||||||
|
identity = mintIdentity()
|
||||||
|
dir, err := preset.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "mcp")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
path = filepath.Join(dir, identity+".json")
|
||||||
|
cfg := map[string]any{
|
||||||
|
"mcpServers": map[string]any{
|
||||||
|
"patterm": map[string]any{
|
||||||
|
"command": l.bin,
|
||||||
|
"args": []string{"mcp-stdio", "--socket", l.mcpSocket, "--identity", identity},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
body = append(body, '\n')
|
||||||
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return identity, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForIdle polls the child's IdleMS until it exceeds idle, or until
|
||||||
|
// max elapses.
|
||||||
|
func waitForIdle(c *Child, idle, max time.Duration) {
|
||||||
|
deadline := time.Now().Add(max)
|
||||||
|
tick := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
<-tick.C
|
||||||
|
if c.Status() != StatusRunning {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.IdleMS() >= idle.Milliseconds() && c.lastWriteNS.Load() != 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinArgs flattens an argv slice into a single line (used for display
|
||||||
|
// hints).
|
||||||
|
func joinArgs(argv []string) string { return strings.Join(argv, " ") }
|
||||||
73
internal/app/layout.go
Normal file
73
internal/app/layout.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
// terminalLayout is the single source of truth for host chrome and the
|
||||||
|
// child PTY viewport.
|
||||||
|
type terminalLayout struct {
|
||||||
|
hostCols uint16
|
||||||
|
hostRows uint16
|
||||||
|
|
||||||
|
mainLeft uint16
|
||||||
|
mainTop uint16
|
||||||
|
mainCols uint16
|
||||||
|
mainRows uint16
|
||||||
|
|
||||||
|
sidebarVisible bool
|
||||||
|
sidebarLeft uint16
|
||||||
|
sidebarWidth uint16
|
||||||
|
|
||||||
|
statusRow uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTerminalLayout(cols, rows uint16) terminalLayout {
|
||||||
|
if cols == 0 {
|
||||||
|
cols = 1
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
rows = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
l := terminalLayout{
|
||||||
|
hostCols: cols,
|
||||||
|
hostRows: rows,
|
||||||
|
mainLeft: 1,
|
||||||
|
mainTop: tabBarRows + 1,
|
||||||
|
mainCols: cols,
|
||||||
|
mainRows: 1,
|
||||||
|
statusRow: rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
if int(cols) > sidebarCols+10 {
|
||||||
|
l.sidebarVisible = true
|
||||||
|
l.sidebarWidth = sidebarCols
|
||||||
|
l.sidebarLeft = cols - sidebarCols + 1
|
||||||
|
l.mainCols = cols - sidebarCols
|
||||||
|
}
|
||||||
|
|
||||||
|
reservedRows := tabBarRows + statusRows
|
||||||
|
if int(rows) > reservedRows {
|
||||||
|
l.mainRows = rows - uint16(reservedRows)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l terminalLayout) childCols() uint16 {
|
||||||
|
if l.mainCols == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return l.mainCols
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l terminalLayout) childRows() uint16 {
|
||||||
|
if l.mainRows == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return l.mainRows
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptyRows(hostRows uint16) uint16 {
|
||||||
|
return newTerminalLayout(1, hostRows).childRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptyCols(hostCols uint16) uint16 {
|
||||||
|
return newTerminalLayout(hostCols, 1).childCols()
|
||||||
|
}
|
||||||
58
internal/app/layout_test.go
Normal file
58
internal/app/layout_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/preset"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
|
||||||
|
l := newTerminalLayout(120, 40)
|
||||||
|
if !l.sidebarVisible {
|
||||||
|
t.Fatal("wide layout should show sidebar")
|
||||||
|
}
|
||||||
|
if l.childCols() != 92 {
|
||||||
|
t.Fatalf("child cols: got %d want 92", l.childCols())
|
||||||
|
}
|
||||||
|
if l.childRows() != 38 {
|
||||||
|
t.Fatalf("child rows: got %d want 38", l.childRows())
|
||||||
|
}
|
||||||
|
if l.mainTop != 2 || l.statusRow != 40 {
|
||||||
|
t.Fatalf("unexpected vertical chrome: mainTop=%d statusRow=%d", l.mainTop, l.statusRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminalLayoutNarrowHidesSidebar(t *testing.T) {
|
||||||
|
l := newTerminalLayout(38, 12)
|
||||||
|
if l.sidebarVisible {
|
||||||
|
t.Fatal("narrow layout should hide sidebar")
|
||||||
|
}
|
||||||
|
if l.childCols() != 38 {
|
||||||
|
t.Fatalf("child cols: got %d want 38", l.childCols())
|
||||||
|
}
|
||||||
|
if l.childRows() != 10 {
|
||||||
|
t.Fatalf("child rows: got %d want 10", l.childRows())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminalLayoutTinyClampsChildSize(t *testing.T) {
|
||||||
|
l := newTerminalLayout(0, 1)
|
||||||
|
if l.childCols() != 1 || l.childRows() != 1 {
|
||||||
|
t.Fatalf("child size: got %dx%d want 1x1", l.childCols(), l.childRows())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnSizingUsesViewportDimensions(t *testing.T) {
|
||||||
|
l := newTerminalLayout(120, 40)
|
||||||
|
launcher := NewLauncher(nil, "", l.childCols(), l.childRows())
|
||||||
|
cols, rows := launcher.size()
|
||||||
|
if cols != 92 || rows != 38 {
|
||||||
|
t.Fatalf("launcher size: got %dx%d want 92x38", cols, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows())
|
||||||
|
cols, rows = host.size()
|
||||||
|
if cols != 92 || rows != 38 {
|
||||||
|
t.Fatalf("tool host size: got %dx%d want 92x38", cols, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
332
internal/app/palette.go
Normal file
332
internal/app/palette.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/preset"
|
||||||
|
)
|
||||||
|
|
||||||
|
// paletteAction is what the palette returns when the user picks an item.
|
||||||
|
type paletteAction struct {
|
||||||
|
// kind: "spawn-agent" | "spawn-process" | "switch" | "kill" | "quit" | "cancel"
|
||||||
|
kind string
|
||||||
|
|
||||||
|
// For spawn-*, the preset to launch.
|
||||||
|
preset *preset.Preset
|
||||||
|
|
||||||
|
// For "switch" and "kill", the target child id.
|
||||||
|
childID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type paletteItem struct {
|
||||||
|
label string
|
||||||
|
hint string
|
||||||
|
action paletteAction
|
||||||
|
}
|
||||||
|
|
||||||
|
// paletteState is the in-memory model for the overlay. SPEC §4: a
|
||||||
|
// single fuzzy-searchable list of commands scoped to the current focus.
|
||||||
|
type paletteState struct {
|
||||||
|
query []rune
|
||||||
|
cursor int
|
||||||
|
children []*Child
|
||||||
|
focused string
|
||||||
|
presets preset.Set
|
||||||
|
|
||||||
|
items []paletteItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPalette(children []*Child, focused string, presets preset.Set) *paletteState {
|
||||||
|
p := &paletteState{children: children, focused: focused, presets: presets}
|
||||||
|
p.rebuild()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *paletteState) rebuild() {
|
||||||
|
all := p.allItems()
|
||||||
|
q := strings.ToLower(string(p.query))
|
||||||
|
if q == "" {
|
||||||
|
p.items = all
|
||||||
|
} else {
|
||||||
|
p.items = p.items[:0]
|
||||||
|
for _, it := range all {
|
||||||
|
if fuzzyMatch(strings.ToLower(it.label+" "+it.hint), q) {
|
||||||
|
p.items = append(p.items, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.cursor >= len(p.items) {
|
||||||
|
p.cursor = len(p.items) - 1
|
||||||
|
}
|
||||||
|
if p.cursor < 0 {
|
||||||
|
p.cursor = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *paletteState) allItems() []paletteItem {
|
||||||
|
var out []paletteItem
|
||||||
|
|
||||||
|
// Preset commands first — SPEC §4 calls these out as the primary
|
||||||
|
// way to spawn anything. One entry per file under presets/.
|
||||||
|
for _, pr := range p.presets.Agents {
|
||||||
|
out = append(out, paletteItem{
|
||||||
|
label: "Spawn agent: " + pr.Name,
|
||||||
|
hint: strings.Join(pr.Argv, " "),
|
||||||
|
action: paletteAction{kind: "spawn-agent", preset: pr},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, pr := range p.presets.Processes {
|
||||||
|
out = append(out, paletteItem{
|
||||||
|
label: "Run process: " + pr.Name,
|
||||||
|
hint: strings.Join(pr.Argv, " "),
|
||||||
|
action: paletteAction{kind: "spawn-process", preset: pr},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch / Kill entries — one per existing child.
|
||||||
|
for _, c := range p.children {
|
||||||
|
label := "Switch to " + c.Name
|
||||||
|
hint := strings.Join(c.Argv, " ")
|
||||||
|
if c.ID == p.focused {
|
||||||
|
label = "• " + label + " (current)"
|
||||||
|
}
|
||||||
|
if c.Status() != StatusRunning {
|
||||||
|
label = label + " [" + string(c.Status()) + "]"
|
||||||
|
}
|
||||||
|
out = append(out, paletteItem{
|
||||||
|
label: label,
|
||||||
|
hint: hint,
|
||||||
|
action: paletteAction{kind: "switch", childID: c.ID},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, c := range p.children {
|
||||||
|
if c.Status() != StatusRunning {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, paletteItem{
|
||||||
|
label: "Kill " + c.Name,
|
||||||
|
hint: "SIGTERM " + strings.Join(c.Argv, " "),
|
||||||
|
action: paletteAction{kind: "kill", childID: c.ID},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, paletteItem{
|
||||||
|
label: "Quit",
|
||||||
|
hint: "exit patterm; SIGTERM every child",
|
||||||
|
action: paletteAction{kind: "quit"},
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzyMatch(hay, needle string) bool {
|
||||||
|
if needle == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
hi := 0
|
||||||
|
for _, r := range needle {
|
||||||
|
idx := strings.IndexRune(hay[hi:], r)
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hi += idx + utf8.RuneLen(r)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *paletteState) handleKey(b byte, peek []byte) (paletteAction, bool) {
|
||||||
|
if b == 0x1b {
|
||||||
|
// Pure Esc cancels; Esc [ A/B is up/down arrow.
|
||||||
|
if len(peek) >= 2 && peek[0] == '[' {
|
||||||
|
switch peek[1] {
|
||||||
|
case 'A':
|
||||||
|
p.cursor--
|
||||||
|
if p.cursor < 0 {
|
||||||
|
p.cursor = 0
|
||||||
|
}
|
||||||
|
return paletteAction{}, false
|
||||||
|
case 'B':
|
||||||
|
p.cursor++
|
||||||
|
if p.cursor >= len(p.items) {
|
||||||
|
p.cursor = len(p.items) - 1
|
||||||
|
}
|
||||||
|
return paletteAction{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paletteAction{kind: "cancel"}, true
|
||||||
|
}
|
||||||
|
switch b {
|
||||||
|
case '\r', '\n':
|
||||||
|
if p.cursor >= 0 && p.cursor < len(p.items) {
|
||||||
|
return p.items[p.cursor].action, true
|
||||||
|
}
|
||||||
|
return paletteAction{kind: "cancel"}, true
|
||||||
|
case 0x7f, 0x08:
|
||||||
|
if len(p.query) > 0 {
|
||||||
|
p.query = p.query[:len(p.query)-1]
|
||||||
|
p.rebuild()
|
||||||
|
}
|
||||||
|
case 0x15: // Ctrl-U
|
||||||
|
p.query = p.query[:0]
|
||||||
|
p.rebuild()
|
||||||
|
case 0x0e: // Ctrl-N
|
||||||
|
p.cursor++
|
||||||
|
if p.cursor >= len(p.items) {
|
||||||
|
p.cursor = len(p.items) - 1
|
||||||
|
}
|
||||||
|
case 0x10: // Ctrl-P inside palette: cursor up.
|
||||||
|
p.cursor--
|
||||||
|
if p.cursor < 0 {
|
||||||
|
p.cursor = 0
|
||||||
|
}
|
||||||
|
case 0x0b: // Ctrl-K inside palette is a no-op (would re-open); ignore.
|
||||||
|
case 0x16: // Ctrl-V literal-paste — ignore in palette.
|
||||||
|
default:
|
||||||
|
if b >= 0x20 && b < 0x7f {
|
||||||
|
p.query = append(p.query, rune(b))
|
||||||
|
p.rebuild()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paletteAction{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// render draws the palette onto out. Geometry: title bar + filter line +
|
||||||
|
// items + footer, centred. The caller is responsible for the screen
|
||||||
|
// clear before the first render.
|
||||||
|
func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
||||||
|
if cols < 20 {
|
||||||
|
cols = 20
|
||||||
|
}
|
||||||
|
if rows < 6 {
|
||||||
|
rows = 6
|
||||||
|
}
|
||||||
|
width := cols - 4
|
||||||
|
if width > 80 {
|
||||||
|
width = 80
|
||||||
|
}
|
||||||
|
if width < 40 {
|
||||||
|
width = cols - 2
|
||||||
|
}
|
||||||
|
leftPad := (cols - width) / 2
|
||||||
|
if leftPad < 1 {
|
||||||
|
leftPad = 1
|
||||||
|
}
|
||||||
|
row := 2
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
|
||||||
|
|
||||||
|
moveTo(&b, row, leftPad)
|
||||||
|
b.WriteString("\x1b[1;7m")
|
||||||
|
b.WriteString(padRight(" patterm — Ctrl-K", width))
|
||||||
|
b.WriteString("\x1b[0m")
|
||||||
|
row++
|
||||||
|
|
||||||
|
moveTo(&b, row, leftPad)
|
||||||
|
b.WriteString("\x1b[7m")
|
||||||
|
b.WriteString(padRight(" › "+string(p.query)+"_", width))
|
||||||
|
b.WriteString("\x1b[0m")
|
||||||
|
row++
|
||||||
|
|
||||||
|
maxItems := rows - 6
|
||||||
|
if maxItems > 12 {
|
||||||
|
maxItems = 12
|
||||||
|
}
|
||||||
|
if maxItems < 1 {
|
||||||
|
maxItems = 1
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
if p.cursor >= maxItems {
|
||||||
|
start = p.cursor - maxItems + 1
|
||||||
|
}
|
||||||
|
end := start + maxItems
|
||||||
|
if end > len(p.items) {
|
||||||
|
end = len(p.items)
|
||||||
|
}
|
||||||
|
for i := start; i < end; i++ {
|
||||||
|
it := p.items[i]
|
||||||
|
moveTo(&b, row, leftPad)
|
||||||
|
if i == p.cursor {
|
||||||
|
b.WriteString("\x1b[7m")
|
||||||
|
} else {
|
||||||
|
b.WriteString("\x1b[0m")
|
||||||
|
}
|
||||||
|
line := " " + it.label
|
||||||
|
if it.hint != "" {
|
||||||
|
line += " \x1b[2m— " + it.hint + "\x1b[0m"
|
||||||
|
if i == p.cursor {
|
||||||
|
line += "\x1b[7m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(padRight(line, width+countAnsi(line)))
|
||||||
|
b.WriteString("\x1b[0m")
|
||||||
|
row++
|
||||||
|
}
|
||||||
|
if len(p.items) == 0 {
|
||||||
|
moveTo(&b, row, leftPad)
|
||||||
|
b.WriteString("\x1b[2m no matches\x1b[0m")
|
||||||
|
row++
|
||||||
|
}
|
||||||
|
|
||||||
|
moveTo(&b, row, leftPad)
|
||||||
|
b.WriteString("\x1b[2m")
|
||||||
|
b.WriteString(padRight(" Enter to run · Esc to close · ↑↓ to navigate", width))
|
||||||
|
b.WriteString("\x1b[0m")
|
||||||
|
|
||||||
|
moveTo(&b, 3, leftPad+4+utf8.RuneCountInString(string(p.query)))
|
||||||
|
b.WriteString("\x1b[?25h")
|
||||||
|
|
||||||
|
_, _ = out.Write([]byte(b.String()))
|
||||||
|
_ = out.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeFlusher interface {
|
||||||
|
Write(p []byte) (int, error)
|
||||||
|
Flush() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeFlusherBase interface {
|
||||||
|
Write(p []byte) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type nopFlusher struct{ io writeFlusherBase }
|
||||||
|
|
||||||
|
func wrapWriter(w writeFlusherBase) writeFlusher { return nopFlusher{io: w} }
|
||||||
|
func (n nopFlusher) Write(p []byte) (int, error) { return n.io.Write(p) }
|
||||||
|
func (n nopFlusher) Flush() error { return nil }
|
||||||
|
|
||||||
|
func moveTo(b *strings.Builder, row, col int) {
|
||||||
|
fmt.Fprintf(b, "\x1b[%d;%dH", row, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
func padRight(s string, width int) string {
|
||||||
|
w := width - visibleLen(s)
|
||||||
|
if w <= 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibleLen(s string) int {
|
||||||
|
n := 0
|
||||||
|
in := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == 0x1b {
|
||||||
|
in = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if in {
|
||||||
|
if r == 'm' || r == 'H' {
|
||||||
|
in = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func countAnsi(s string) int {
|
||||||
|
return len(s) - visibleLen(s)
|
||||||
|
}
|
||||||
86
internal/app/screen_renderer.go
Normal file
86
internal/app/screen_renderer.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/vt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderScreenSnapshot(text string, cursor vt.CursorState, layout terminalLayout) []byte {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||||
|
cols := int(layout.childCols())
|
||||||
|
rows := int(layout.childRows())
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\x1b[?25l")
|
||||||
|
for r := 0; r < rows; r++ {
|
||||||
|
line := ""
|
||||||
|
if r < len(lines) {
|
||||||
|
line = truncateCells(lines[r], cols)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\x1b[%d;%dH%s", int(layout.mainTop)+r, int(layout.mainLeft), padRight(line, cols))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cursor.Visible {
|
||||||
|
row := int(layout.mainTop) + int(cursor.Row)
|
||||||
|
col := int(layout.mainLeft) + int(cursor.Col)
|
||||||
|
if row < int(layout.mainTop) {
|
||||||
|
row = int(layout.mainTop)
|
||||||
|
}
|
||||||
|
maxRow := int(layout.mainTop) + rows - 1
|
||||||
|
if row > maxRow {
|
||||||
|
row = maxRow
|
||||||
|
}
|
||||||
|
if col < int(layout.mainLeft) {
|
||||||
|
col = int(layout.mainLeft)
|
||||||
|
}
|
||||||
|
maxCol := int(layout.mainLeft) + cols - 1
|
||||||
|
if col > maxCol {
|
||||||
|
col = maxCol
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\x1b[?25h\x1b[%d;%dH", row, col)
|
||||||
|
}
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCursor(cursor vt.CursorState, layout terminalLayout) []byte {
|
||||||
|
cols := int(layout.childCols())
|
||||||
|
rows := int(layout.childRows())
|
||||||
|
row := int(layout.mainTop) + int(cursor.Row)
|
||||||
|
col := int(layout.mainLeft) + int(cursor.Col)
|
||||||
|
if row < int(layout.mainTop) {
|
||||||
|
row = int(layout.mainTop)
|
||||||
|
}
|
||||||
|
maxRow := int(layout.mainTop) + rows - 1
|
||||||
|
if row > maxRow {
|
||||||
|
row = maxRow
|
||||||
|
}
|
||||||
|
if col < int(layout.mainLeft) {
|
||||||
|
col = int(layout.mainLeft)
|
||||||
|
}
|
||||||
|
maxCol := int(layout.mainLeft) + cols - 1
|
||||||
|
if col > maxCol {
|
||||||
|
col = maxCol
|
||||||
|
}
|
||||||
|
return []byte(fmt.Sprintf("\x1b[?25h\x1b[%d;%dH", row, col))
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateCells(s string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if visibleLen(s) <= width {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
n := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if n >= width {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
33
internal/app/screen_renderer_test.go
Normal file
33
internal/app/screen_renderer_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/vt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderScreenSnapshotClipsRowsToViewport(t *testing.T) {
|
||||||
|
layout := newTerminalLayout(20, 5)
|
||||||
|
got := string(renderScreenSnapshot("abcdefghijklmnopqrstuvwxy\nsecond", vt.CursorState{}, layout))
|
||||||
|
if strings.Contains(got, "uvwxy") {
|
||||||
|
t.Fatalf("line leaked past viewport width: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "\x1b[2;1Habcdefghijklmnopqrst") {
|
||||||
|
t.Fatalf("first row not drawn at viewport top: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "\x1b[3;1Hsecond ") {
|
||||||
|
t.Fatalf("second row not padded in viewport: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "\x1b[4;1H ") {
|
||||||
|
t.Fatalf("blank viewport row not cleared: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderScreenSnapshotPlacesCursorInsideViewport(t *testing.T) {
|
||||||
|
layout := newTerminalLayout(20, 5)
|
||||||
|
got := string(renderScreenSnapshot("abc", vt.CursorState{Col: 2, Row: 1, Visible: true}, layout))
|
||||||
|
if !strings.HasSuffix(got, "\x1b[?25h\x1b[3;3H") {
|
||||||
|
t.Fatalf("cursor not placed inside viewport: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
315
internal/app/session.go
Normal file
315
internal/app/session.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
// Package app is patterm's single foreground process. It owns the TUI,
|
||||||
|
// every PTY, every emulator, the in-process MCP server, and the
|
||||||
|
// scratchpad/preset state.
|
||||||
|
//
|
||||||
|
// There is no daemon, no detach, no socket-based client/daemon split
|
||||||
|
// (SPEC §2). One process owns everything; closing the terminal window
|
||||||
|
// ends the session and tears down every child.
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/vt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session is the in-memory state for the running patterm process.
|
||||||
|
// In SPEC §4 terms each top-level tab is a session; v1 ships with a
|
||||||
|
// single implicit session and reserves room to grow.
|
||||||
|
type Session struct {
|
||||||
|
projectDir string
|
||||||
|
projectKey string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
children map[string]*Child
|
||||||
|
order []string
|
||||||
|
|
||||||
|
nextChildSeq atomic.Int64
|
||||||
|
|
||||||
|
// listeners is the set of UI listeners that want to hear about child
|
||||||
|
// lifecycle events (spawn/exit) — exactly one (the TUI) in v1.
|
||||||
|
listenersMu sync.Mutex
|
||||||
|
listeners []ChildEventListener
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChildEventListener is implemented by the TUI to react to lifecycle
|
||||||
|
// events without polling.
|
||||||
|
type ChildEventListener interface {
|
||||||
|
OnChildSpawned(*Child)
|
||||||
|
OnChildExited(*Child)
|
||||||
|
// OnPTYOut is called for every chunk the child writes to its PTY.
|
||||||
|
// Only the focused-child chunk should reach the screen — the TUI
|
||||||
|
// filters by id.
|
||||||
|
OnPTYOut(childID string, chunk []byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSession(projectDir, projectKey string) *Session {
|
||||||
|
return &Session{
|
||||||
|
projectDir: projectDir,
|
||||||
|
projectKey: projectKey,
|
||||||
|
children: make(map[string]*Child),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Subscribe(l ChildEventListener) {
|
||||||
|
s.listenersMu.Lock()
|
||||||
|
defer s.listenersMu.Unlock()
|
||||||
|
s.listeners = append(s.listeners, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) emitSpawn(c *Child) {
|
||||||
|
s.listenersMu.Lock()
|
||||||
|
ls := append([]ChildEventListener(nil), s.listeners...)
|
||||||
|
s.listenersMu.Unlock()
|
||||||
|
for _, l := range ls {
|
||||||
|
l.OnChildSpawned(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) emitExit(c *Child) {
|
||||||
|
s.listenersMu.Lock()
|
||||||
|
ls := append([]ChildEventListener(nil), s.listeners...)
|
||||||
|
s.listenersMu.Unlock()
|
||||||
|
for _, l := range ls {
|
||||||
|
l.OnChildExited(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) emitPTYOut(id string, chunk []byte) {
|
||||||
|
s.listenersMu.Lock()
|
||||||
|
ls := append([]ChildEventListener(nil), s.listeners...)
|
||||||
|
s.listenersMu.Unlock()
|
||||||
|
for _, l := range ls {
|
||||||
|
l.OnPTYOut(id, chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) ChildEnv() []string {
|
||||||
|
env := os.Environ()
|
||||||
|
// Mark patterm-owned PTYs so a recursive `patterm` invocation can
|
||||||
|
// detect it and degrade. The MCP socket is per-PID and lives under
|
||||||
|
// $XDG_RUNTIME_DIR — see internal/mcp.
|
||||||
|
env = append(env,
|
||||||
|
"PATTERM=1",
|
||||||
|
"PATTERM_PROJECT_KEY="+s.projectKey,
|
||||||
|
"PATTERM_PROJECT_DIR="+s.projectDir,
|
||||||
|
)
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn launches a new child with the given argv. kind is "agent" or
|
||||||
|
// "process". parentID names the calling session/child for orchestrator
|
||||||
|
// trees ("" for top-level). env is the full child environment; the
|
||||||
|
// caller is responsible for adding preset-specific overrides.
|
||||||
|
func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
id := fmt.Sprintf("c%d", s.nextChildSeq.Add(1))
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("%s-%s", kind, id)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if env == nil {
|
||||||
|
env = s.ChildEnv()
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := newChild(id, name, kind, argv, env, cols, rows, parentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.children[id] = c
|
||||||
|
s.order = append(s.order, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.emitSpawn(c)
|
||||||
|
go s.pumpChild(c)
|
||||||
|
go s.reapChild(c)
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnWithIdentity is like Spawn but lets the launcher pre-mint the
|
||||||
|
// MCP identity so the config file can be written before the process
|
||||||
|
// starts.
|
||||||
|
func (s *Session) spawnWithIdentity(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, identity string) (*Child, error) {
|
||||||
|
c, err := s.Spawn(name, kind, argv, env, cols, rows, parentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.Identity = identity
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) pumpChild(c *Child) {
|
||||||
|
buf := make([]byte, 64*1024)
|
||||||
|
for {
|
||||||
|
n, err := c.pty.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := make([]byte, n)
|
||||||
|
copy(chunk, buf[:n])
|
||||||
|
if _, werr := c.em.Write(chunk); werr != nil {
|
||||||
|
logf("emulator.Write(child %s): %v", c.ID, werr)
|
||||||
|
}
|
||||||
|
c.recordWrite(chunk)
|
||||||
|
s.emitPTYOut(c.ID, chunk)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, syscall.EIO) && !errors.Is(err, os.ErrClosed) {
|
||||||
|
logf("pty read (child %s): %v", c.ID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) reapChild(c *Child) {
|
||||||
|
err := c.pty.Wait()
|
||||||
|
c.markExited(err)
|
||||||
|
logf("child %s exited (err=%v)", c.ID, err)
|
||||||
|
s.emitExit(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children returns a snapshot of children in spawn order.
|
||||||
|
func (s *Session) Children() []*Child {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]*Child, 0, len(s.order))
|
||||||
|
for _, id := range s.order {
|
||||||
|
if c, ok := s.children[id]; ok {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindChild looks up a child by id; returns nil if not present.
|
||||||
|
func (s *Session) FindChild(id string) *Child {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.children[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindChildByIdentity finds the child whose Identity matches token.
|
||||||
|
// Used by MCP to bind a mcp-stdio greeting to its caller. Returns nil
|
||||||
|
// if no match.
|
||||||
|
func (s *Session) FindChildByIdentity(token string) *Child {
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, c := range s.children {
|
||||||
|
if c.Identity == token {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill sends a signal (default SIGTERM) to a child by id.
|
||||||
|
func (s *Session) Kill(id string, sig syscall.Signal) error {
|
||||||
|
c := s.FindChild(id)
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("no such child %q", id)
|
||||||
|
}
|
||||||
|
if sig == 0 {
|
||||||
|
sig = syscall.SIGTERM
|
||||||
|
}
|
||||||
|
return c.signal(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteInput pipes bytes to a child's PTY stdin.
|
||||||
|
func (s *Session) WriteInput(id string, b []byte) error {
|
||||||
|
c := s.FindChild(id)
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("no such child %q", id)
|
||||||
|
}
|
||||||
|
if c.Status() != StatusRunning {
|
||||||
|
return fmt.Errorf("child %q is %s", id, c.Status())
|
||||||
|
}
|
||||||
|
_, err := c.pty.Write(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizeAll updates every child's PTY + emulator to the same cell grid.
|
||||||
|
// SPEC §5 says one viewport, no multi-client resize negotiation.
|
||||||
|
func (s *Session) ResizeAll(cols, rows uint16) {
|
||||||
|
if cols == 0 || rows == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
cs := make([]*Child, 0, len(s.children))
|
||||||
|
for _, c := range s.children {
|
||||||
|
cs = append(cs, c)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, c := range cs {
|
||||||
|
_ = c.pty.Resize(cols, rows)
|
||||||
|
_ = c.em.Resize(cols, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeChild returns the VT bytes that reproduce the child's
|
||||||
|
// current screen state. Used to repaint a child after the user switches
|
||||||
|
// focus or closes the palette.
|
||||||
|
func (s *Session) SerializeChild(id string) ([]byte, error) {
|
||||||
|
c := s.FindChild(id)
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("no such child %q", id)
|
||||||
|
}
|
||||||
|
return c.em.SerializeVT()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
|
||||||
|
c := s.FindChild(id)
|
||||||
|
if c == nil {
|
||||||
|
return "", vt.CursorState{}, fmt.Errorf("no such child %q", id)
|
||||||
|
}
|
||||||
|
text, err := c.em.ScreenText()
|
||||||
|
if err != nil {
|
||||||
|
return "", vt.CursorState{}, err
|
||||||
|
}
|
||||||
|
cursor, err := c.em.Cursor()
|
||||||
|
if err != nil {
|
||||||
|
return "", vt.CursorState{}, err
|
||||||
|
}
|
||||||
|
return text, cursor, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown kills every child and waits briefly for them to drain.
|
||||||
|
// Called on Ctrl-D / SIGTERM / SIGHUP. SPEC §2 step 4.
|
||||||
|
func (s *Session) Shutdown() {
|
||||||
|
s.mu.Lock()
|
||||||
|
cs := make([]*Child, 0, len(s.children))
|
||||||
|
for _, c := range s.children {
|
||||||
|
cs = append(cs, c)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, c := range cs {
|
||||||
|
_ = c.signal(syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
// Close emulators and PTY masters. The reaper goroutines will fire
|
||||||
|
// emitExit as Wait() returns.
|
||||||
|
for _, c := range cs {
|
||||||
|
_ = c.pty.Close()
|
||||||
|
_ = c.em.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logf(format string, args ...any) {
|
||||||
|
if os.Getenv("PATTERM_DEBUG_LOG") == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(os.Getenv("PATTERM_DEBUG_LOG"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
fmt.Fprintf(f, "patterm: "+format+"\n", args...)
|
||||||
|
}
|
||||||
143
internal/app/sidebar.go
Normal file
143
internal/app/sidebar.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sidebarCols = 28
|
||||||
|
statusRows = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// drawSidebar paints the right-rail session tree + scratchpad list.
|
||||||
|
// SPEC §4: the rail is the active session's child hierarchy on top and
|
||||||
|
// the scratchpad list (with preview) on the bottom.
|
||||||
|
//
|
||||||
|
// Implementation note: the PTY child's winsize is constrained to the
|
||||||
|
// computed main viewport, so the sidebar region is outside the child's
|
||||||
|
// cursor range. We can redraw freely without fighting the child for cells.
|
||||||
|
func (st *uiState) drawSidebar() {
|
||||||
|
st.mu.Lock()
|
||||||
|
palOpen := st.palette != nil
|
||||||
|
focus := st.focusedID
|
||||||
|
st.mu.Unlock()
|
||||||
|
if palOpen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layout := st.layoutSnapshot()
|
||||||
|
if !layout.sidebarVisible || layout.hostRows < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
left := int(layout.sidebarLeft)
|
||||||
|
width := int(layout.sidebarWidth) - 1
|
||||||
|
maxRow := int(layout.statusRow) - statusRows
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
// Border column at left-1: a single vertical pipe.
|
||||||
|
for r := 1; r <= maxRow; r++ {
|
||||||
|
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[2m│\x1b[0m", r, left-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := 1
|
||||||
|
writeLine := func(s string, style string) {
|
||||||
|
if row > maxRow {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(s) > width {
|
||||||
|
s = s[:width]
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\x1b[%d;%dH%s%s\x1b[0m\x1b[K", row, left, style, padRight(s, width))
|
||||||
|
row++
|
||||||
|
}
|
||||||
|
|
||||||
|
writeLine(" Session tree", "\x1b[1m")
|
||||||
|
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
|
||||||
|
|
||||||
|
children := visibleSessionTree(st.sess.Children(), focus)
|
||||||
|
if len(children) == 0 {
|
||||||
|
writeLine(" (empty)", "\x1b[2m")
|
||||||
|
}
|
||||||
|
for _, c := range children {
|
||||||
|
glyph := "◉"
|
||||||
|
marker := " "
|
||||||
|
if c.ID == focus {
|
||||||
|
marker = "▶ "
|
||||||
|
}
|
||||||
|
indent := ""
|
||||||
|
if c.ParentID != "" {
|
||||||
|
indent = " "
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf(" %s%s%s %s", marker, indent, glyph, c.Name)
|
||||||
|
style := ""
|
||||||
|
if c.ID == focus {
|
||||||
|
style = "\x1b[1m"
|
||||||
|
}
|
||||||
|
writeLine(line, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scratchpads list — pick the most-recently-modified one as the
|
||||||
|
// preview target. SPEC §4.
|
||||||
|
var previewName string
|
||||||
|
if row+2 <= maxRow {
|
||||||
|
row++
|
||||||
|
writeLine(" Scratchpads", "\x1b[1m")
|
||||||
|
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
|
||||||
|
entries, err := st.pads.List()
|
||||||
|
if err == nil {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
writeLine(" (none)", "\x1b[2m")
|
||||||
|
}
|
||||||
|
var newest string
|
||||||
|
var newestTS string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.ModifiedAt > newestTS {
|
||||||
|
newestTS = e.ModifiedAt
|
||||||
|
newest = e.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewName = newest
|
||||||
|
for _, e := range entries {
|
||||||
|
if row > maxRow {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
marker := " "
|
||||||
|
style := ""
|
||||||
|
if e.Name == previewName {
|
||||||
|
marker = " ▸ "
|
||||||
|
style = "\x1b[1m"
|
||||||
|
}
|
||||||
|
writeLine(marker+e.Name, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview pane at the bottom of the rail. Reserve up to 8 rows.
|
||||||
|
if previewName != "" && row+2 <= maxRow {
|
||||||
|
row++
|
||||||
|
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
|
||||||
|
writeLine(" "+previewName, "\x1b[1m")
|
||||||
|
content, _, err := st.pads.Read(previewName)
|
||||||
|
if err == nil {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
if row > maxRow {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
writeLine(" "+line, "\x1b[2m")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank-fill any rows the rail content didn't cover so stale
|
||||||
|
// content from a previous redraw doesn't linger.
|
||||||
|
for row <= maxRow {
|
||||||
|
writeLine("", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
st.outMu.Lock()
|
||||||
|
// Save cursor; emit the sidebar; restore.
|
||||||
|
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
|
||||||
|
st.outMu.Unlock()
|
||||||
|
}
|
||||||
70
internal/app/tabbar.go
Normal file
70
internal/app/tabbar.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tabBarRows = 1
|
||||||
|
|
||||||
|
// drawTabBar renders SPEC §4's top tab bar at row 1. Tabs are top-level
|
||||||
|
// children (ParentID == ""); the focused tab is highlighted. The PTY
|
||||||
|
// region begins at row 2.
|
||||||
|
func (st *uiState) drawTabBar() {
|
||||||
|
st.mu.Lock()
|
||||||
|
palOpen := st.palette != nil
|
||||||
|
focus := st.focusedID
|
||||||
|
st.mu.Unlock()
|
||||||
|
if palOpen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
layout := st.layoutSnapshot()
|
||||||
|
width := int(layout.childCols())
|
||||||
|
|
||||||
|
var sessions []*Child
|
||||||
|
for _, c := range st.sess.Children() {
|
||||||
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
||||||
|
sessions = append(sessions, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\x1b[1;1H")
|
||||||
|
cur := 0
|
||||||
|
for _, c := range sessions {
|
||||||
|
label := c.Name
|
||||||
|
seg := " " + label + " "
|
||||||
|
if cur+len(seg) > width-2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if c.ID == focus {
|
||||||
|
b.WriteString("\x1b[7m")
|
||||||
|
} else {
|
||||||
|
b.WriteString("\x1b[2m")
|
||||||
|
}
|
||||||
|
b.WriteString(seg)
|
||||||
|
b.WriteString("\x1b[0m")
|
||||||
|
cur += len(seg)
|
||||||
|
}
|
||||||
|
// "+" hint at end.
|
||||||
|
hint := "+"
|
||||||
|
if cur > 0 {
|
||||||
|
hint = " +"
|
||||||
|
}
|
||||||
|
if cur+len(hint) <= width {
|
||||||
|
b.WriteString("\x1b[2m")
|
||||||
|
b.WriteString(hint)
|
||||||
|
b.WriteString("\x1b[0m")
|
||||||
|
cur += len(hint)
|
||||||
|
}
|
||||||
|
// Fill the rest of the tab-bar row so stale chars don't linger.
|
||||||
|
if width-cur > 0 {
|
||||||
|
b.WriteString(strings.Repeat(" ", width-cur))
|
||||||
|
}
|
||||||
|
|
||||||
|
st.outMu.Lock()
|
||||||
|
defer st.outMu.Unlock()
|
||||||
|
// Save cursor, paint, restore.
|
||||||
|
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
|
||||||
|
}
|
||||||
59
internal/app/tree.go
Normal file
59
internal/app/tree.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
func visibleSessionTree(children []*Child, focusID string) []*Child {
|
||||||
|
rootID := activeRootID(children, focusID)
|
||||||
|
if rootID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*Child, 0, len(children))
|
||||||
|
for _, c := range children {
|
||||||
|
if c.Status() != StatusRunning {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.ID == rootID || c.ParentID == rootID {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeRootID(children []*Child, focusID string) string {
|
||||||
|
if focusID != "" {
|
||||||
|
for _, c := range children {
|
||||||
|
if c.ID != focusID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.ParentID == "" {
|
||||||
|
return c.ID
|
||||||
|
}
|
||||||
|
if parent := findChildInSnapshot(children, c.ParentID); parent != nil {
|
||||||
|
return parent.ID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range children {
|
||||||
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
||||||
|
return c.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findChildInSnapshot(children []*Child, id string) *Child {
|
||||||
|
for _, c := range children {
|
||||||
|
if c.ID == id {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstRunningTopLevel(children []*Child) *Child {
|
||||||
|
for _, c := range children {
|
||||||
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
41
internal/app/tree_test.go
Normal file
41
internal/app/tree_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestVisibleSessionTreeScopesToFocusedRoot(t *testing.T) {
|
||||||
|
root1 := testChild("c1", "root1", "", StatusRunning)
|
||||||
|
child1 := testChild("c2", "child1", "c1", StatusRunning)
|
||||||
|
root2 := testChild("c3", "root2", "", StatusRunning)
|
||||||
|
child2 := testChild("c4", "child2", "c3", StatusRunning)
|
||||||
|
|
||||||
|
got := visibleSessionTree([]*Child{root1, child1, root2, child2}, "c4")
|
||||||
|
if len(got) != 2 || got[0].ID != "c3" || got[1].ID != "c4" {
|
||||||
|
t.Fatalf("visible tree = %v, want root2 + child2", childIDs(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisibleSessionTreeOmitsExited(t *testing.T) {
|
||||||
|
root := testChild("c1", "root", "", StatusRunning)
|
||||||
|
exitedRoot := testChild("c2", "dead-root", "", StatusExited)
|
||||||
|
runningChild := testChild("c3", "child", "c1", StatusRunning)
|
||||||
|
exitedChild := testChild("c4", "dead-child", "c1", StatusExited)
|
||||||
|
|
||||||
|
got := visibleSessionTree([]*Child{root, exitedRoot, runningChild, exitedChild}, "c1")
|
||||||
|
if len(got) != 2 || got[0].ID != "c1" || got[1].ID != "c3" {
|
||||||
|
t.Fatalf("visible tree = %v, want running root + running child", childIDs(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChild(id, name, parent string, status ChildStatus) *Child {
|
||||||
|
c := &Child{ID: id, Name: name, ParentID: parent}
|
||||||
|
c.status.Store(&status)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func childIDs(cs []*Child) []string {
|
||||||
|
ids := make([]string, 0, len(cs))
|
||||||
|
for _, c := range cs {
|
||||||
|
ids = append(ids, c.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
294
internal/app/viewport_renderer.go
Normal file
294
internal/app/viewport_renderer.go
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// viewportRenderer rewrites child PTY output so it lands inside the
|
||||||
|
// main viewport instead of controlling patterm's full host terminal.
|
||||||
|
type viewportRenderer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
shifter *cursorShifter
|
||||||
|
layout terminalLayout
|
||||||
|
row int
|
||||||
|
col int
|
||||||
|
|
||||||
|
state viewportState
|
||||||
|
buf []byte
|
||||||
|
pending strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
type viewportState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
vpNormal viewportState = iota
|
||||||
|
vpEsc
|
||||||
|
vpCSI
|
||||||
|
vpOSC
|
||||||
|
vpOSCEsc
|
||||||
|
vpDCS
|
||||||
|
vpDCSEsc
|
||||||
|
vpSOSPMAPC
|
||||||
|
vpSOSPMAPCEsc
|
||||||
|
)
|
||||||
|
|
||||||
|
func newViewportRenderer(l terminalLayout) *viewportRenderer {
|
||||||
|
return &viewportRenderer{
|
||||||
|
shifter: newCursorShifter(int(l.mainTop) - 1),
|
||||||
|
layout: l,
|
||||||
|
row: 1,
|
||||||
|
col: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vr *viewportRenderer) SetLayout(l terminalLayout) {
|
||||||
|
vr.mu.Lock()
|
||||||
|
defer vr.mu.Unlock()
|
||||||
|
vr.layout = l
|
||||||
|
vr.shifter.SetRowOffset(int(l.mainTop) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vr *viewportRenderer) Render(in []byte) []byte {
|
||||||
|
vr.mu.Lock()
|
||||||
|
defer vr.mu.Unlock()
|
||||||
|
vr.pending.Reset()
|
||||||
|
for _, b := range in {
|
||||||
|
vr.feed(b)
|
||||||
|
}
|
||||||
|
return []byte(vr.pending.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vr *viewportRenderer) feed(b byte) {
|
||||||
|
switch vr.state {
|
||||||
|
case vpNormal:
|
||||||
|
if b == 0x1b {
|
||||||
|
vr.state = vpEsc
|
||||||
|
vr.buf = vr.buf[:0]
|
||||||
|
vr.buf = append(vr.buf, b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vr.pending.WriteByte(b)
|
||||||
|
vr.advancePrintable(b)
|
||||||
|
case vpEsc:
|
||||||
|
vr.buf = append(vr.buf, b)
|
||||||
|
switch b {
|
||||||
|
case '[':
|
||||||
|
vr.state = vpCSI
|
||||||
|
case ']':
|
||||||
|
vr.state = vpOSC
|
||||||
|
case 'P':
|
||||||
|
vr.state = vpDCS
|
||||||
|
case 'X', '^', '_':
|
||||||
|
vr.state = vpSOSPMAPC
|
||||||
|
default:
|
||||||
|
vr.pending.Write(vr.buf)
|
||||||
|
vr.state = vpNormal
|
||||||
|
vr.buf = vr.buf[:0]
|
||||||
|
}
|
||||||
|
case vpCSI:
|
||||||
|
vr.buf = append(vr.buf, b)
|
||||||
|
if isCSIFinal(b) {
|
||||||
|
vr.emitCSI()
|
||||||
|
vr.state = vpNormal
|
||||||
|
vr.buf = vr.buf[:0]
|
||||||
|
}
|
||||||
|
case vpOSC:
|
||||||
|
vr.buf = append(vr.buf, b)
|
||||||
|
switch b {
|
||||||
|
case 0x07:
|
||||||
|
vr.pending.Write(vr.buf)
|
||||||
|
vr.state = vpNormal
|
||||||
|
vr.buf = vr.buf[:0]
|
||||||
|
case 0x1b:
|
||||||
|
vr.state = vpOSCEsc
|
||||||
|
}
|
||||||
|
case vpOSCEsc:
|
||||||
|
vr.buf = append(vr.buf, b)
|
||||||
|
vr.pending.Write(vr.buf)
|
||||||
|
vr.state = vpNormal
|
||||||
|
vr.buf = vr.buf[:0]
|
||||||
|
case vpDCS:
|
||||||
|
vr.buf = append(vr.buf, b)
|
||||||
|
if b == 0x1b {
|
||||||
|
vr.state = vpDCSEsc
|
||||||
|
}
|
||||||
|
case vpDCSEsc:
|
||||||
|
vr.buf = append(vr.buf, b)
|
||||||
|
vr.pending.Write(vr.buf)
|
||||||
|
vr.state = vpNormal
|
||||||
|
vr.buf = vr.buf[:0]
|
||||||
|
case vpSOSPMAPC:
|
||||||
|
vr.buf = append(vr.buf, b)
|
||||||
|
if b == 0x1b {
|
||||||
|
vr.state = vpSOSPMAPCEsc
|
||||||
|
}
|
||||||
|
case vpSOSPMAPCEsc:
|
||||||
|
vr.buf = append(vr.buf, b)
|
||||||
|
vr.pending.Write(vr.buf)
|
||||||
|
vr.state = vpNormal
|
||||||
|
vr.buf = vr.buf[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vr *viewportRenderer) emitCSI() {
|
||||||
|
if len(vr.buf) < 3 {
|
||||||
|
vr.pending.Write(vr.buf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
final := vr.buf[len(vr.buf)-1]
|
||||||
|
params := vr.buf[2 : len(vr.buf)-1]
|
||||||
|
|
||||||
|
if final == 'h' || final == 'l' {
|
||||||
|
if isAltScreenMode(params) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch final {
|
||||||
|
case 'J':
|
||||||
|
n, ok := parseOneParam(params, 0)
|
||||||
|
if !ok {
|
||||||
|
vr.pending.Write(vr.shifter.Shift(vr.buf))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch n {
|
||||||
|
case 2, 3:
|
||||||
|
vr.pending.WriteString(vr.clearViewport())
|
||||||
|
default:
|
||||||
|
vr.pending.Write(vr.shifter.Shift(vr.buf))
|
||||||
|
}
|
||||||
|
case 'K':
|
||||||
|
n, ok := parseOneParam(params, 0)
|
||||||
|
if !ok {
|
||||||
|
vr.pending.Write(vr.shifter.Shift(vr.buf))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vr.pending.WriteString(vr.clearLine(n))
|
||||||
|
default:
|
||||||
|
vr.pending.Write(vr.shifter.Shift(vr.buf))
|
||||||
|
}
|
||||||
|
vr.trackCSI(final, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAltScreenMode(params []byte) bool {
|
||||||
|
s := string(params)
|
||||||
|
if !strings.HasPrefix(s, "?") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
|
||||||
|
switch p {
|
||||||
|
case "47", "1047", "1049":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vr *viewportRenderer) clearViewport() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\x1b7")
|
||||||
|
for r := uint16(0); r < vr.layout.childRows(); r++ {
|
||||||
|
fmt.Fprintf(&b, "\x1b[%d;%dH%s", int(vr.layout.mainTop+r), int(vr.layout.mainLeft), strings.Repeat(" ", int(vr.layout.childCols())))
|
||||||
|
}
|
||||||
|
b.WriteString("\x1b8")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vr *viewportRenderer) clearLine(n int) string {
|
||||||
|
right := int(vr.layout.childCols())
|
||||||
|
if vr.col < 1 {
|
||||||
|
vr.col = 1
|
||||||
|
}
|
||||||
|
if vr.col > right {
|
||||||
|
vr.col = right
|
||||||
|
}
|
||||||
|
switch n {
|
||||||
|
case 0:
|
||||||
|
return "\x1b[" + strconv.Itoa(right-vr.col+1) + "X"
|
||||||
|
case 1:
|
||||||
|
return "\x1b7\r\x1b[" + strconv.Itoa(vr.col) + "X\x1b8"
|
||||||
|
case 2:
|
||||||
|
return "\x1b7\r\x1b[" + strconv.Itoa(right) + "X\x1b8"
|
||||||
|
default:
|
||||||
|
return "\x1b[" + strconv.Itoa(right-vr.col+1) + "X"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vr *viewportRenderer) advancePrintable(b byte) {
|
||||||
|
switch b {
|
||||||
|
case '\r':
|
||||||
|
vr.col = 1
|
||||||
|
case '\n':
|
||||||
|
vr.row++
|
||||||
|
case '\b':
|
||||||
|
if vr.col > 1 {
|
||||||
|
vr.col--
|
||||||
|
}
|
||||||
|
case '\t':
|
||||||
|
vr.col += 8 - ((vr.col - 1) % 8)
|
||||||
|
default:
|
||||||
|
if b >= 0x20 && b != 0x7f {
|
||||||
|
vr.col++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vr.clampCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
|
||||||
|
switch final {
|
||||||
|
case 'H', 'f':
|
||||||
|
r, c, ok := parseTwoParams(params)
|
||||||
|
if ok {
|
||||||
|
vr.row, vr.col = r, c
|
||||||
|
}
|
||||||
|
case 'G', '`':
|
||||||
|
c, ok := parseOneParam(params, 1)
|
||||||
|
if ok {
|
||||||
|
vr.col = c
|
||||||
|
}
|
||||||
|
case 'd':
|
||||||
|
r, ok := parseOneParam(params, 1)
|
||||||
|
if ok {
|
||||||
|
vr.row = r
|
||||||
|
}
|
||||||
|
case 'A':
|
||||||
|
n, ok := parseOneParam(params, 1)
|
||||||
|
if ok {
|
||||||
|
vr.row -= n
|
||||||
|
}
|
||||||
|
case 'B', 'e':
|
||||||
|
n, ok := parseOneParam(params, 1)
|
||||||
|
if ok {
|
||||||
|
vr.row += n
|
||||||
|
}
|
||||||
|
case 'C', 'a':
|
||||||
|
n, ok := parseOneParam(params, 1)
|
||||||
|
if ok {
|
||||||
|
vr.col += n
|
||||||
|
}
|
||||||
|
case 'D':
|
||||||
|
n, ok := parseOneParam(params, 1)
|
||||||
|
if ok {
|
||||||
|
vr.col -= n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vr.clampCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vr *viewportRenderer) clampCursor() {
|
||||||
|
if vr.row < 1 {
|
||||||
|
vr.row = 1
|
||||||
|
}
|
||||||
|
if vr.col < 1 {
|
||||||
|
vr.col = 1
|
||||||
|
}
|
||||||
|
if max := int(vr.layout.childRows()); vr.row > max {
|
||||||
|
vr.row = max
|
||||||
|
}
|
||||||
|
if max := int(vr.layout.childCols()); vr.col > max {
|
||||||
|
vr.col = max
|
||||||
|
}
|
||||||
|
}
|
||||||
63
internal/app/viewport_renderer_test.go
Normal file
63
internal/app/viewport_renderer_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestViewportRendererShiftsCursor(t *testing.T) {
|
||||||
|
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||||
|
got := string(vr.Render([]byte("\x1b[H")))
|
||||||
|
if got != "\x1b[2;1H" {
|
||||||
|
t.Fatalf("CUP home: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
|
||||||
|
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||||
|
got := string(vr.Render([]byte("a\x1b[?1049hb\x1b[?1049lc")))
|
||||||
|
if got != "abc" {
|
||||||
|
t.Fatalf("alt-screen toggles: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
|
||||||
|
vr := newViewportRenderer(newTerminalLayout(20, 5))
|
||||||
|
got := string(vr.Render([]byte("\x1b[2J")))
|
||||||
|
if strings.Contains(got, "\x1b[2J") {
|
||||||
|
t.Fatalf("host clear-screen leaked through: %q", got)
|
||||||
|
}
|
||||||
|
if strings.Count(got, " ") != 3 {
|
||||||
|
t.Fatalf("clear rows: got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "\x1b[2;1H") || !strings.Contains(got, "\x1b[4;1H") {
|
||||||
|
t.Fatalf("clear did not target viewport rows: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewportRendererClearLineUsesEraseChars(t *testing.T) {
|
||||||
|
vr := newViewportRenderer(newTerminalLayout(20, 5))
|
||||||
|
got := string(vr.Render([]byte("\x1b[K")))
|
||||||
|
if strings.Contains(got, "\x1b[K") {
|
||||||
|
t.Fatalf("host clear-line leaked through: %q", got)
|
||||||
|
}
|
||||||
|
if got != "\x1b[20X" {
|
||||||
|
t.Fatalf("clear-line: got %q want ECH", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewportRendererClearLineStopsAtViewportRight(t *testing.T) {
|
||||||
|
vr := newViewportRenderer(newTerminalLayout(20, 5))
|
||||||
|
got := string(vr.Render([]byte("\x1b[10G\x1b[K")))
|
||||||
|
if !strings.HasSuffix(got, "\x1b[11X") {
|
||||||
|
t.Fatalf("clear-line from col 10 should erase 11 cells: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewportRendererTracksPrintableCursor(t *testing.T) {
|
||||||
|
vr := newViewportRenderer(newTerminalLayout(20, 5))
|
||||||
|
got := string(vr.Render([]byte("hello\x1b[K")))
|
||||||
|
if !strings.HasSuffix(got, "\x1b[15X") {
|
||||||
|
t.Fatalf("clear-line after five chars should erase 15 cells: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
183
internal/mcp/mcp.go
Normal file
183
internal/mcp/mcp.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
// Package mcp is patterm's in-process MCP server and the stdio proxy
|
||||||
|
// subcommand that spawned children connect through. SPEC §7 + §10.
|
||||||
|
//
|
||||||
|
// v1 stubs out the server: it listens on the per-PID socket, accepts
|
||||||
|
// connections from `patterm mcp-stdio` proxies, and returns a "not
|
||||||
|
// implemented" error for every tool call. The plumbing is in place so
|
||||||
|
// later milestones (suggested build order §15 step 4 onwards) can fill
|
||||||
|
// in real tool handlers without touching the lifecycle code.
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server is patterm's in-process MCP server. SPEC §10: bound to a
|
||||||
|
// per-PID unix socket under $XDG_RUNTIME_DIR/patterm/<pid>.sock.
|
||||||
|
type Server struct {
|
||||||
|
socket string
|
||||||
|
listener net.Listener
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
host ToolHost
|
||||||
|
}
|
||||||
|
|
||||||
|
// SocketPath returns the per-PID socket path with the standard fallback.
|
||||||
|
// SPEC §3.
|
||||||
|
func SocketPath() (string, error) {
|
||||||
|
pid := strconv.Itoa(os.Getpid())
|
||||||
|
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
|
||||||
|
dir := filepath.Join(runtime, "patterm")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return "", fmt.Errorf("mcp: mkdir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, pid+".sock"), nil
|
||||||
|
}
|
||||||
|
return filepath.Join("/tmp", "patterm-"+pid+".sock"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start opens the per-PID socket and serves JSON-RPC over it. The
|
||||||
|
// returned Server can be Close()d on shutdown.
|
||||||
|
func Start() (*Server, error) {
|
||||||
|
path, err := SocketPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = os.Remove(path)
|
||||||
|
ln, err := net.Listen("unix", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mcp: listen %s: %w", path, err)
|
||||||
|
}
|
||||||
|
_ = os.Chmod(path, 0o600)
|
||||||
|
s := &Server{socket: path, listener: ln}
|
||||||
|
go s.acceptLoop()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Socket() string { return s.socket }
|
||||||
|
|
||||||
|
func (s *Server) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
_ = s.listener.Close()
|
||||||
|
_ = os.Remove(s.socket)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) acceptLoop() {
|
||||||
|
for {
|
||||||
|
conn, err := s.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, net.ErrClosed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go s.handleConn(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConn reads newline-delimited JSON-RPC requests from a connected
|
||||||
|
// child and dispatches them. The first line carries the per-spawn
|
||||||
|
// identity token (SPEC §10); we resolve it to a child id and stash that
|
||||||
|
// as the caller for every subsequent tool call.
|
||||||
|
func (s *Server) handleConn(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
r := bufio.NewReader(conn)
|
||||||
|
|
||||||
|
var callerID string
|
||||||
|
|
||||||
|
greeting, err := r.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tok := greetingIdentity(greeting); tok != "" {
|
||||||
|
s.mu.Lock()
|
||||||
|
host := s.host
|
||||||
|
s.mu.Unlock()
|
||||||
|
if host != nil {
|
||||||
|
callerID = host.ResolveCallerIdentity(tok)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Treat as a real request from an unknown caller.
|
||||||
|
resp := s.dispatch("", greeting)
|
||||||
|
resp = append(resp, '\n')
|
||||||
|
if _, werr := conn.Write(resp); werr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := r.ReadBytes('\n')
|
||||||
|
if len(line) > 0 {
|
||||||
|
resp := s.dispatch(callerID, line)
|
||||||
|
resp = append(resp, '\n')
|
||||||
|
if _, werr := conn.Write(resp); werr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func greetingIdentity(b []byte) string {
|
||||||
|
var probe struct {
|
||||||
|
Identity string `json:"patterm_identity"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &probe); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return probe.Identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunStdioProxy is the entry point for `patterm mcp-stdio`. It opens
|
||||||
|
// the per-PID socket and shuttles bytes between os.Stdin/os.Stdout and
|
||||||
|
// the socket. SPEC §10: the vendor CLI thinks it's launching a normal
|
||||||
|
// stdio MCP server; this proxy forwards JSON-RPC to the running
|
||||||
|
// patterm process.
|
||||||
|
func RunStdioProxy(socket, identity string) error {
|
||||||
|
conn, err := net.Dial("unix", socket)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial %s: %w", socket, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Send a one-line greeting carrying the identity so the server
|
||||||
|
// knows which child it's talking to. Format: {"patterm_identity":
|
||||||
|
// "<token>"} + newline. Real protocol handshake is a later
|
||||||
|
// milestone.
|
||||||
|
greeting := map[string]string{"patterm_identity": identity}
|
||||||
|
gb, _ := json.Marshal(greeting)
|
||||||
|
gb = append(gb, '\n')
|
||||||
|
if _, err := conn.Write(gb); err != nil {
|
||||||
|
return fmt.Errorf("greeting: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 2)
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(conn, os.Stdin)
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(os.Stdout, conn)
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
<-errCh
|
||||||
|
return nil
|
||||||
|
}
|
||||||
347
internal/mcp/tools.go
Normal file
347
internal/mcp/tools.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/harrybrwn/patterm/internal/scratchpad"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToolHost is the interface the in-process server uses to reach the
|
||||||
|
// running patterm process's state. The app package implements this so
|
||||||
|
// internal/mcp doesn't import internal/app (which would be a cycle).
|
||||||
|
type ToolHost interface {
|
||||||
|
Children() []ChildInfo
|
||||||
|
Spawn(callerID, name string, argv []string, shell bool) (ChildInfo, error)
|
||||||
|
SpawnAgent(callerID, presetName, displayName, initialPrompt string) (ChildInfo, error)
|
||||||
|
ReadOutput(callerID, childID, mode string, sinceOffset int) (content string, newOffset int, err error)
|
||||||
|
SendInput(callerID, childID string, payload []byte, appendNewline bool) error
|
||||||
|
Kill(callerID, childID string, sig syscall.Signal) error
|
||||||
|
SendMessageTo(callerID, targetID, message string) error
|
||||||
|
ReportToParent(callerID, message string) error
|
||||||
|
TimerWait(callerID string, seconds float64, label string) (string, error)
|
||||||
|
WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (matched bool, snippet string, err error)
|
||||||
|
RequestHumanAttention(callerID, childID, reason string) error
|
||||||
|
Scratchpads() *scratchpad.Store
|
||||||
|
|
||||||
|
// ResolveCallerIdentity translates a per-spawn identity token into
|
||||||
|
// the child ID the server stores in its connection state.
|
||||||
|
ResolveCallerIdentity(identity string) string
|
||||||
|
|
||||||
|
// PolicyCheck — SPEC §9. Returns "allow" / "punt" / "unknown" for
|
||||||
|
// a candidate auto-answer prompt the orchestrator is reading.
|
||||||
|
PolicyCheck(prompt string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChildInfo is what list_children / spawn_process / spawn_agent return.
|
||||||
|
// Matches SPEC §7 shape plus the §11 idle exposure.
|
||||||
|
type ChildInfo struct {
|
||||||
|
ID string `json:"child_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode int `json:"exit_code,omitempty"`
|
||||||
|
IdleMS int64 `json:"idle_ms,omitempty"`
|
||||||
|
ParentID string `json:"parent_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) SetHost(h ToolHost) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.host = h
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch routes a single JSON-RPC request. callerID is the ID of the
|
||||||
|
// child that owns this connection (resolved at greeting time).
|
||||||
|
func (s *Server) dispatch(callerID string, req []byte) []byte {
|
||||||
|
var msg struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req, &msg); err != nil {
|
||||||
|
return jsonRPCError(nil, -32700, "parse error: "+err.Error())
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
host := s.host
|
||||||
|
s.mu.Unlock()
|
||||||
|
if host == nil {
|
||||||
|
return jsonRPCError(msg.ID, -32000, "patterm: tool host not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, code, errMsg := callTool(host, callerID, msg.Method, msg.Params)
|
||||||
|
if errMsg != "" {
|
||||||
|
return jsonRPCError(msg.ID, code, errMsg)
|
||||||
|
}
|
||||||
|
return jsonRPCResult(msg.ID, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, int, string) {
|
||||||
|
switch method {
|
||||||
|
case "list_children":
|
||||||
|
return h.Children(), 0, ""
|
||||||
|
|
||||||
|
case "spawn_process":
|
||||||
|
var p struct {
|
||||||
|
Preset string `json:"preset"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
Shell bool `json:"shell"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
WorkingDir string `json:"working_dir"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
// Preset-by-name is the preferred path per SPEC §7; argv is the
|
||||||
|
// escape hatch. We don't load process presets here — the host
|
||||||
|
// is the source of truth — so a named preset call is rejected
|
||||||
|
// unless the caller also supplied argv. (Wiring full preset
|
||||||
|
// resolution into MCP is a small follow-up; the host's palette
|
||||||
|
// path covers the named case today.)
|
||||||
|
if len(p.Argv) == 0 {
|
||||||
|
return nil, -32602, "spawn_process: argv required"
|
||||||
|
}
|
||||||
|
ci, err := h.Spawn(callerID, p.Name, p.Argv, p.Shell)
|
||||||
|
if err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return ci, 0, ""
|
||||||
|
|
||||||
|
case "spawn_agent":
|
||||||
|
var p struct {
|
||||||
|
Preset string `json:"preset"`
|
||||||
|
InitialPrompt string `json:"initial_prompt"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
if p.Preset == "" {
|
||||||
|
return nil, -32602, "spawn_agent: preset required"
|
||||||
|
}
|
||||||
|
ci, err := h.SpawnAgent(callerID, p.Preset, p.Name, p.InitialPrompt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return ci, 0, ""
|
||||||
|
|
||||||
|
case "read_output":
|
||||||
|
var p struct {
|
||||||
|
ChildID string `json:"child_id"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
SinceOffset int `json:"since_offset"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
if p.Mode == "" {
|
||||||
|
p.Mode = "grid"
|
||||||
|
}
|
||||||
|
content, newOff, err := h.ReadOutput(callerID, p.ChildID, p.Mode, p.SinceOffset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"content": content,
|
||||||
|
"new_offset": newOff,
|
||||||
|
"mode": p.Mode,
|
||||||
|
}, 0, ""
|
||||||
|
|
||||||
|
case "send_input":
|
||||||
|
var p struct {
|
||||||
|
ChildID string `json:"child_id"`
|
||||||
|
Input string `json:"input"`
|
||||||
|
AppendNewline *bool `json:"append_newline"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
appendNL := true
|
||||||
|
if p.AppendNewline != nil {
|
||||||
|
appendNL = *p.AppendNewline
|
||||||
|
}
|
||||||
|
if err := h.SendInput(callerID, p.ChildID, []byte(p.Input), appendNL); err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return "ok", 0, ""
|
||||||
|
|
||||||
|
case "kill":
|
||||||
|
var p struct {
|
||||||
|
ChildID string `json:"child_id"`
|
||||||
|
Signal int `json:"signal"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
sig := syscall.Signal(p.Signal)
|
||||||
|
if sig == 0 {
|
||||||
|
sig = syscall.SIGTERM
|
||||||
|
}
|
||||||
|
if err := h.Kill(callerID, p.ChildID, sig); err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return "ok", 0, ""
|
||||||
|
|
||||||
|
case "send_message_to":
|
||||||
|
var p struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
if err := h.SendMessageTo(callerID, p.Target, p.Message); err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return "ok", 0, ""
|
||||||
|
|
||||||
|
case "report_to_parent":
|
||||||
|
var p struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
if err := h.ReportToParent(callerID, p.Message); err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return "ok", 0, ""
|
||||||
|
|
||||||
|
case "timer_wait":
|
||||||
|
var p struct {
|
||||||
|
Seconds float64 `json:"seconds"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
id, err := h.TimerWait(callerID, p.Seconds, p.Label)
|
||||||
|
if err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return map[string]string{"timer_id": id}, 0, ""
|
||||||
|
|
||||||
|
case "wait_for_pattern":
|
||||||
|
var p struct {
|
||||||
|
ChildID string `json:"child_id"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
TimeoutSeconds float64 `json:"timeout_seconds"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
matched, snippet, err := h.WaitForPattern(callerID, p.ChildID, p.Pattern, p.TimeoutSeconds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return map[string]any{"matched": matched, "snippet": snippet}, 0, ""
|
||||||
|
|
||||||
|
case "request_human_attention":
|
||||||
|
var p struct {
|
||||||
|
ChildID string `json:"child_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
if err := h.RequestHumanAttention(callerID, p.ChildID, p.Reason); err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return "ok", 0, ""
|
||||||
|
|
||||||
|
case "scratchpad_list":
|
||||||
|
entries, err := h.Scratchpads().List()
|
||||||
|
if err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return entries, 0, ""
|
||||||
|
|
||||||
|
case "scratchpad_read":
|
||||||
|
var p struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
content, rev, err := h.Scratchpads().Read(p.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return map[string]any{"content": content, "revision": rev}, 0, ""
|
||||||
|
|
||||||
|
case "scratchpad_write":
|
||||||
|
var p struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ExpectedRevision string `json:"expected_revision"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
rev, err := h.Scratchpads().Write(p.Name, p.Content, p.ExpectedRevision)
|
||||||
|
if err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return map[string]any{"revision": rev}, 0, ""
|
||||||
|
|
||||||
|
case "policy_check":
|
||||||
|
var p struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
return map[string]string{"decision": h.PolicyCheck(p.Prompt)}, 0, ""
|
||||||
|
|
||||||
|
case "scratchpad_append":
|
||||||
|
var p struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := unmarshalParams(params, &p); err != nil {
|
||||||
|
return nil, -32602, err.Error()
|
||||||
|
}
|
||||||
|
if err := h.Scratchpads().Append(p.Name, p.Content); err != nil {
|
||||||
|
return nil, -32000, err.Error()
|
||||||
|
}
|
||||||
|
return "ok", 0, ""
|
||||||
|
}
|
||||||
|
return nil, -32601, "method not found: " + method
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalParams(raw json.RawMessage, out any) error {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return errors.New("missing params")
|
||||||
|
}
|
||||||
|
return json.Unmarshal(raw, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonRPCResult(id json.RawMessage, result any) []byte {
|
||||||
|
resp := map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": id,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(resp)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonRPCError(id json.RawMessage, code int, message string) []byte {
|
||||||
|
resp := map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": id,
|
||||||
|
"error": map[string]any{
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(resp)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time guard: every dispatch path is covered. fmt is imported
|
||||||
|
// only so future error wrapping can land without re-adding the import.
|
||||||
|
var _ = fmt.Sprintf
|
||||||
166
internal/policy/policy.go
Normal file
166
internal/policy/policy.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// Package policy implements SPEC §9 permissions hooks.
|
||||||
|
//
|
||||||
|
// patterm doesn't enforce permissions on the agent's behalf — the
|
||||||
|
// orchestrator is the policy actor. But patterm ships a config that
|
||||||
|
// surfaces the project's deny-list to the orchestrator (via
|
||||||
|
// scratchpad_read("policy.md")) and exposes a Should() helper future
|
||||||
|
// MCP middleware can call to short-circuit obviously-dangerous prompts.
|
||||||
|
//
|
||||||
|
// File location: $XDG_CONFIG_HOME/patterm/policy.json (global default;
|
||||||
|
// per-project override at <project>/.patterm/policy.json is a v2
|
||||||
|
// follow-up).
|
||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decision is what Should() returns for a candidate auto-answer.
|
||||||
|
type Decision string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Allow: the prompt is in the always-safe allowlist; auto-answer.
|
||||||
|
Allow Decision = "allow"
|
||||||
|
// PuntToHuman: the prompt matches the deny-list; the orchestrator
|
||||||
|
// MUST call request_human_attention instead of auto-answering.
|
||||||
|
PuntToHuman Decision = "punt"
|
||||||
|
// Unknown: no rule applies. SPEC §9 says default is to punt; we
|
||||||
|
// keep this distinct so callers know it's a default, not a match.
|
||||||
|
Unknown Decision = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Policy struct {
|
||||||
|
// Allowlist patterns: prompts matching ANY of these are safe to
|
||||||
|
// auto-answer.
|
||||||
|
AllowPatterns []string `json:"allow_patterns"`
|
||||||
|
// Deny patterns: prompts matching ANY of these MUST be punted to
|
||||||
|
// the human. Default seed below covers SPEC §9 examples (writes,
|
||||||
|
// deletes, sudo, package install, broad shell).
|
||||||
|
DenyPatterns []string `json:"deny_patterns"`
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
compiledAOK bool
|
||||||
|
allowRE []*regexp.Regexp
|
||||||
|
denyRE []*regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns the seeded policy that ships out of the box.
|
||||||
|
func Default() *Policy {
|
||||||
|
return &Policy{
|
||||||
|
AllowPatterns: []string{
|
||||||
|
`(?i)read.* from .*\?`,
|
||||||
|
`(?i)open .* in editor\?`,
|
||||||
|
`(?i)show diff\?`,
|
||||||
|
},
|
||||||
|
DenyPatterns: []string{
|
||||||
|
`(?i)sudo`,
|
||||||
|
`(?i)rm -rf`,
|
||||||
|
`(?i)delete .*permanently`,
|
||||||
|
`(?i)install package`,
|
||||||
|
`(?i)pip install`,
|
||||||
|
`(?i)npm install -g`,
|
||||||
|
`(?i)curl .* \| .*sh`,
|
||||||
|
`(?i)wget .* \| .*sh`,
|
||||||
|
`(?i)force.push`,
|
||||||
|
`(?i)git push --force`,
|
||||||
|
`(?i)drop (table|database)`,
|
||||||
|
`(?i)\.ssh/`,
|
||||||
|
`(?i)\.aws/credentials`,
|
||||||
|
`(?i)\.env\b`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the user's policy, falling back to Default if absent.
|
||||||
|
// Errors other than ENOENT are returned.
|
||||||
|
func Load() (*Policy, error) {
|
||||||
|
p, err := PathFor()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
pol := Default()
|
||||||
|
_ = Save(pol)
|
||||||
|
return pol, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var pol Policy
|
||||||
|
if err := json.Unmarshal(body, &pol); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pol, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes p to the standard location, creating directories.
|
||||||
|
func Save(p *Policy) error {
|
||||||
|
path, err := PathFor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := json.MarshalIndent(p, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = append(body, '\n')
|
||||||
|
return os.WriteFile(path, body, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PathFor() (string, error) {
|
||||||
|
if h := os.Getenv("XDG_CONFIG_HOME"); h != "" {
|
||||||
|
return filepath.Join(h, "patterm", "policy.json"), nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "patterm", "policy.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should classifies a candidate auto-answer prompt the orchestrator is
|
||||||
|
// reading from a sub-agent's grid.
|
||||||
|
func (p *Policy) Should(promptText string) Decision {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.ensureCompiledLocked()
|
||||||
|
for _, re := range p.denyRE {
|
||||||
|
if re.MatchString(promptText) {
|
||||||
|
return PuntToHuman
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, re := range p.allowRE {
|
||||||
|
if re.MatchString(promptText) {
|
||||||
|
return Allow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Policy) ensureCompiledLocked() {
|
||||||
|
if p.compiledAOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.allowRE = make([]*regexp.Regexp, 0, len(p.AllowPatterns))
|
||||||
|
for _, s := range p.AllowPatterns {
|
||||||
|
if re, err := regexp.Compile(s); err == nil {
|
||||||
|
p.allowRE = append(p.allowRE, re)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.denyRE = make([]*regexp.Regexp, 0, len(p.DenyPatterns))
|
||||||
|
for _, s := range p.DenyPatterns {
|
||||||
|
if re, err := regexp.Compile(s); err == nil {
|
||||||
|
p.denyRE = append(p.denyRE, re)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.compiledAOK = true
|
||||||
|
}
|
||||||
254
internal/preset/preset.go
Normal file
254
internal/preset/preset.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
// Package preset loads user-editable JSON files that describe how to
|
||||||
|
// launch agents and processes. SPEC §10: every spawnable thing is a
|
||||||
|
// preset; patterm has no hard-coded agent or process types.
|
||||||
|
package preset
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind is "agent" or "process".
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindAgent Kind = "agent"
|
||||||
|
KindProcess Kind = "process"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preset is one loaded preset file. Agent-only fields stay zero on
|
||||||
|
// process presets and vice versa.
|
||||||
|
type Preset struct {
|
||||||
|
// Source path (informational; not serialized).
|
||||||
|
Path string `json:"-"`
|
||||||
|
Kind Kind `json:"-"`
|
||||||
|
|
||||||
|
Name string `json:"name"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
WorkingDir string `json:"working_dir,omitempty"`
|
||||||
|
|
||||||
|
// Process-only.
|
||||||
|
Shell bool `json:"shell,omitempty"`
|
||||||
|
|
||||||
|
// Agent-only. SPEC §10.
|
||||||
|
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
|
||||||
|
ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
|
||||||
|
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPInjection covers the three strategies SPEC §10 enumerates: a CLI
|
||||||
|
// flag (claude --mcp-config ...), an external config file we merge into
|
||||||
|
// (codex ~/.codex/config.toml), or an env var.
|
||||||
|
type MCPInjection struct {
|
||||||
|
Kind string `json:"kind"` // "flag" | "config_file" | "env_var"
|
||||||
|
Flag string `json:"flag,omitempty"`
|
||||||
|
ConfigPath string `json:"config_path,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
MergeKey string `json:"merge_key,omitempty"`
|
||||||
|
Var string `json:"var,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadySignal lets a preset override the default 1s-idle heuristic.
|
||||||
|
type ReadySignal struct {
|
||||||
|
IdleMS int `json:"idle_ms,omitempty"`
|
||||||
|
Pattern string `json:"pattern,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set is what the palette consumes.
|
||||||
|
type Set struct {
|
||||||
|
Agents []*Preset
|
||||||
|
Processes []*Preset
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load scans the standard locations under $XDG_CONFIG_HOME/patterm/
|
||||||
|
// presets/{agents,processes}/*.json. Unknown files are skipped with a
|
||||||
|
// warning to stderr; the spec is forgiving here.
|
||||||
|
func Load() (Set, error) {
|
||||||
|
base, err := ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return Set{}, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(base, 0o700); err != nil {
|
||||||
|
return Set{}, fmt.Errorf("preset: mkdir %s: %w", base, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the default-preset files exist on first run. Idempotent.
|
||||||
|
if err := ensureDefaults(base); err != nil {
|
||||||
|
return Set{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
agents, err := loadDir(filepath.Join(base, "presets", "agents"), KindAgent)
|
||||||
|
if err != nil {
|
||||||
|
return Set{}, err
|
||||||
|
}
|
||||||
|
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindProcess)
|
||||||
|
if err != nil {
|
||||||
|
return Set{}, err
|
||||||
|
}
|
||||||
|
return Set{Agents: agents, Processes: procs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigDir resolves $XDG_CONFIG_HOME/patterm (with the conventional
|
||||||
|
// fallback to ~/.config/patterm).
|
||||||
|
func ConfigDir() (string, error) {
|
||||||
|
if h := os.Getenv("XDG_CONFIG_HOME"); h != "" {
|
||||||
|
return filepath.Join(h, "patterm"), nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "patterm"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDir(dir string, kind Kind) ([]*Preset, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("preset: mkdir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("preset: read %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
var out []*Preset
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, e.Name())
|
||||||
|
p, err := loadFile(path, kind)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFile(path string, kind Kind) (*Preset, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var p Preset
|
||||||
|
if err := json.Unmarshal(b, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.Name == "" {
|
||||||
|
return nil, errors.New("missing 'name'")
|
||||||
|
}
|
||||||
|
if len(p.Argv) == 0 && !p.Shell {
|
||||||
|
return nil, errors.New("missing 'argv'")
|
||||||
|
}
|
||||||
|
p.Path = path
|
||||||
|
p.Kind = kind
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvedArgv returns the argv to actually exec, handling the
|
||||||
|
// process-preset "shell: true" case (SPEC §10).
|
||||||
|
func (p *Preset) ResolvedArgv() []string {
|
||||||
|
if p.Shell && len(p.Argv) > 0 {
|
||||||
|
return []string{"sh", "-lc", strings.Join(p.Argv, " ")}
|
||||||
|
}
|
||||||
|
return p.Argv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDefaults writes default agent presets (claude/codex/opencode)
|
||||||
|
// and a sample process preset on first run. Never overwrites existing
|
||||||
|
// user files.
|
||||||
|
func ensureDefaults(base string) error {
|
||||||
|
defaults := []struct {
|
||||||
|
rel string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"presets/agents/claude.json",
|
||||||
|
`{
|
||||||
|
"name": "claude",
|
||||||
|
"argv": ["claude"],
|
||||||
|
"mcp_injection": { "kind": "flag", "flag": "--mcp-config" },
|
||||||
|
"ready_signal": { "idle_ms": 1000 },
|
||||||
|
"chrome_trim_hints": [
|
||||||
|
"^Welcome to Claude Code",
|
||||||
|
"^/help for help",
|
||||||
|
"^cwd:",
|
||||||
|
"^\\s*│\\s*>",
|
||||||
|
"^\\s*╭─+╮$",
|
||||||
|
"^\\s*╰─+╯$",
|
||||||
|
"^\\? for shortcuts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"presets/agents/codex.json",
|
||||||
|
`{
|
||||||
|
"name": "codex",
|
||||||
|
"argv": ["codex"],
|
||||||
|
"mcp_injection": { "kind": "config_file", "path": "~/.codex/config.toml", "merge_key": "mcp_servers" },
|
||||||
|
"ready_signal": { "idle_ms": 1000 },
|
||||||
|
"chrome_trim_hints": [
|
||||||
|
"^OpenAI Codex",
|
||||||
|
"^\\s*model:",
|
||||||
|
"^\\s*workdir:",
|
||||||
|
"^>_$",
|
||||||
|
"^\\s*▌"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"presets/agents/opencode.json",
|
||||||
|
`{
|
||||||
|
"name": "opencode",
|
||||||
|
"argv": ["opencode"],
|
||||||
|
"mcp_injection": { "kind": "config_file", "path": "~/.config/opencode/opencode.json", "merge_key": "mcp" },
|
||||||
|
"ready_signal": { "idle_ms": 1000 },
|
||||||
|
"chrome_trim_hints": [
|
||||||
|
"^\\s*█",
|
||||||
|
"^\\s*opencode v",
|
||||||
|
"^\\s*~/",
|
||||||
|
"^\\s*>_"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"presets/processes/shell.json",
|
||||||
|
`{
|
||||||
|
"name": "shell",
|
||||||
|
"argv": ["__SHELL__"]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, d := range defaults {
|
||||||
|
full := filepath.Join(base, d.rel)
|
||||||
|
if _, err := os.Stat(full); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := d.body
|
||||||
|
if strings.Contains(body, "__SHELL__") {
|
||||||
|
shell := os.Getenv("SHELL")
|
||||||
|
if shell == "" {
|
||||||
|
shell = "/bin/sh"
|
||||||
|
}
|
||||||
|
body = strings.ReplaceAll(body, "__SHELL__", shell)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(body), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
32
internal/projectkey/projectkey.go
Normal file
32
internal/projectkey/projectkey.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Package projectkey turns a project directory into the stable key
|
||||||
|
// patterm uses to name its scratchpad directory under $XDG_DATA_HOME.
|
||||||
|
// SPEC §3.
|
||||||
|
//
|
||||||
|
// Two invocations from the same realpath must produce the same key.
|
||||||
|
// The key is only used as a directory name on disk — there is no
|
||||||
|
// daemon to look up.
|
||||||
|
package projectkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Key derives the 16-char hex key from the realpath of dir.
|
||||||
|
func Key(dir string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("projectkey: abs %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
resolved, err := filepath.EvalSymlinks(abs)
|
||||||
|
if err != nil {
|
||||||
|
// Directory may not exist yet; fall back to the absolute path.
|
||||||
|
// The key stays stable; downstream code will fail later when it
|
||||||
|
// tries to chdir or write into the dir.
|
||||||
|
resolved = abs
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(resolved))
|
||||||
|
return hex.EncodeToString(sum[:8]), nil
|
||||||
|
}
|
||||||
142
internal/pty/pty.go
Normal file
142
internal/pty/pty.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// Package pty wraps creack/pty with the small surface the spike needs.
|
||||||
|
package pty
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
cpty "github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PTY holds a child process attached to a pseudo-terminal master fd.
|
||||||
|
type PTY struct {
|
||||||
|
master *os.File
|
||||||
|
cmd *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start spawns argv with stdin/stdout/stderr attached to a new PTY sized
|
||||||
|
// (cols, rows). The returned PTY exposes the master fd for the parent to
|
||||||
|
// read from and write to.
|
||||||
|
func Start(argv []string, env []string, cols, rows uint16) (*PTY, error) {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return nil, fmt.Errorf("pty: empty argv")
|
||||||
|
}
|
||||||
|
cmd := exec.Command(argv[0], argv[1:]...)
|
||||||
|
if env != nil {
|
||||||
|
cmd.Env = ensureTerm(env)
|
||||||
|
} else {
|
||||||
|
// Default to the parent environment but force TERM to xterm-256color
|
||||||
|
// so child programs assume something modern and we observe SGR + alt
|
||||||
|
// screen sequences.
|
||||||
|
cmd.Env = ensureTerm(os.Environ())
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := &cpty.Winsize{Cols: cols, Rows: rows}
|
||||||
|
master, err := cpty.StartWithSize(cmd, ws)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pty: start %v: %w", argv, err)
|
||||||
|
}
|
||||||
|
return &PTY{master: master, cmd: cmd}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PTY) Read(b []byte) (int, error) {
|
||||||
|
if p.master == nil {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
return p.master.Read(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PTY) Write(b []byte) (int, error) {
|
||||||
|
if p.master == nil {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
return p.master.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PTY) Resize(cols, rows uint16) error {
|
||||||
|
if p.master == nil {
|
||||||
|
return io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
return cpty.Setsize(p.master, &cpty.Winsize{Cols: cols, Rows: rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until the child exits and returns its exit error if any.
|
||||||
|
func (p *PTY) Wait() error {
|
||||||
|
if p.cmd == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pid returns the child's PID, or -1 if the process is not running.
|
||||||
|
func (p *PTY) Pid() int {
|
||||||
|
if p.cmd == nil || p.cmd.Process == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return p.cmd.Process.Pid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the child (best effort) and releases the master fd.
|
||||||
|
func (p *PTY) Close() error {
|
||||||
|
var firstErr error
|
||||||
|
if p.master != nil {
|
||||||
|
if err := p.master.Close(); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
p.master = nil
|
||||||
|
}
|
||||||
|
if p.cmd != nil && p.cmd.Process != nil {
|
||||||
|
_ = p.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// envDefaults are added to the child's environment unless the parent already
|
||||||
|
// set them. Modern agent TUIs check these and silently downgrade rendering
|
||||||
|
// when they're missing (no truecolor, ASCII-only banners, etc.).
|
||||||
|
var envDefaults = map[string]string{
|
||||||
|
"TERM": "xterm-256color",
|
||||||
|
"COLORTERM": "truecolor",
|
||||||
|
}
|
||||||
|
|
||||||
|
// envStrip names variables we DROP before launching a child. COLUMNS /
|
||||||
|
// LINES inherited from the parent shell describe the *host* terminal,
|
||||||
|
// not the PTY we created — when they leak through, TUIs that prefer
|
||||||
|
// env over TIOCGWINSZ render past the PTY's actual cell grid and
|
||||||
|
// overwrite our chrome.
|
||||||
|
var envStrip = map[string]bool{
|
||||||
|
"COLUMNS": true,
|
||||||
|
"LINES": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureTerm(env []string) []string {
|
||||||
|
have := make(map[string]bool, len(envDefaults))
|
||||||
|
out := make([]string, 0, len(env)+len(envDefaults))
|
||||||
|
for _, kv := range env {
|
||||||
|
key := envKey(kv)
|
||||||
|
if envStrip[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, isDefault := envDefaults[key]; isDefault {
|
||||||
|
have[key] = true
|
||||||
|
}
|
||||||
|
out = append(out, kv)
|
||||||
|
}
|
||||||
|
for k, v := range envDefaults {
|
||||||
|
if !have[k] {
|
||||||
|
out = append(out, k+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func envKey(kv string) string {
|
||||||
|
for i := 0; i < len(kv); i++ {
|
||||||
|
if kv[i] == '=' {
|
||||||
|
return kv[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kv
|
||||||
|
}
|
||||||
138
internal/scratchpad/scratchpad.go
Normal file
138
internal/scratchpad/scratchpad.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Package scratchpad manages the project-scoped markdown files described
|
||||||
|
// in SPEC §3. Files live under
|
||||||
|
// $XDG_DATA_HOME/patterm/projects/<project-key>/scratchpads/. Last-write-
|
||||||
|
// wins with a revision token (SPEC §14).
|
||||||
|
package scratchpad
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the per-project scratchpad directory.
|
||||||
|
type Store struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open returns a Store rooted at the SPEC §3 path for projectKey.
|
||||||
|
func Open(projectKey string) (*Store, error) {
|
||||||
|
base, err := DataDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(base, "projects", projectKey, "scratchpads")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("scratchpad: mkdir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
return &Store{dir: dir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataDir resolves $XDG_DATA_HOME/patterm with the conventional fallback.
|
||||||
|
func DataDir() (string, error) {
|
||||||
|
if h := os.Getenv("XDG_DATA_HOME"); h != "" {
|
||||||
|
return filepath.Join(h, "patterm"), nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".local", "share", "patterm"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry is what List returns; SPEC §7 `scratchpad_list` shape.
|
||||||
|
type Entry struct {
|
||||||
|
Name string
|
||||||
|
Size int64
|
||||||
|
ModifiedAt string // RFC3339; kept as string so MCP serialization is trivial later
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Dir() string { return s.dir }
|
||||||
|
|
||||||
|
func (s *Store) List() ([]Entry, error) {
|
||||||
|
entries, err := os.ReadDir(s.dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Entry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Entry{
|
||||||
|
Name: e.Name(),
|
||||||
|
Size: info.Size(),
|
||||||
|
ModifiedAt: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Read(name string) (content string, revision string, err error) {
|
||||||
|
p, err := s.safePath(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return string(b), revisionOf(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write replaces the file's contents. expectedRevision, if non-empty,
|
||||||
|
// must match the current revision or the write is rejected (SPEC §14
|
||||||
|
// last-write-wins-with-token).
|
||||||
|
func (s *Store) Write(name, content, expectedRevision string) (string, error) {
|
||||||
|
p, err := s.safePath(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if expectedRevision != "" {
|
||||||
|
if cur, err := os.ReadFile(p); err == nil {
|
||||||
|
if revisionOf(cur) != expectedRevision {
|
||||||
|
return "", fmt.Errorf("scratchpad: revision mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return revisionOf([]byte(content)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Append(name, content string) error {
|
||||||
|
p, err := s.safePath(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = f.WriteString(content)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) safePath(name string) (string, error) {
|
||||||
|
if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." {
|
||||||
|
return "", errors.New("scratchpad: invalid name")
|
||||||
|
}
|
||||||
|
return filepath.Join(s.dir, name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func revisionOf(b []byte) string {
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(sum[:6])
|
||||||
|
}
|
||||||
62
internal/vt/emulator.go
Normal file
62
internal/vt/emulator.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Package vt wraps a headless virtual terminal emulator behind a small
|
||||||
|
// Go interface. The intent is that all cgo to libghostty-vt is confined
|
||||||
|
// to the GhosttyEmulator implementation in this package.
|
||||||
|
package vt
|
||||||
|
|
||||||
|
// Screen identifies which buffer is currently displayed.
|
||||||
|
type Screen uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScreenPrimary Screen = iota
|
||||||
|
ScreenAlternate
|
||||||
|
)
|
||||||
|
|
||||||
|
// CursorState is a snapshot of cursor position and visibility.
|
||||||
|
type CursorState struct {
|
||||||
|
Col, Row uint16
|
||||||
|
Visible bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emulator is the headless VT used by the daemon (and by the milestone-1 spike).
|
||||||
|
//
|
||||||
|
// Implementations are not required to be safe for concurrent use. The spike
|
||||||
|
// CLI funnels all calls through a single goroutine.
|
||||||
|
type Emulator interface {
|
||||||
|
// Write feeds bytes from the PTY master into the emulator. It returns
|
||||||
|
// the number of bytes consumed (always len(p) on success).
|
||||||
|
Write(p []byte) (int, error)
|
||||||
|
|
||||||
|
// Resize updates the emulator's cell grid. The caller is responsible
|
||||||
|
// for issuing TIOCSWINSZ on the PTY itself.
|
||||||
|
Resize(cols, rows uint16) error
|
||||||
|
|
||||||
|
// PlainText returns the active screen rendered as plain text, with
|
||||||
|
// soft-wrapped lines unwrapped and trailing whitespace trimmed.
|
||||||
|
PlainText() (string, error)
|
||||||
|
|
||||||
|
// ScreenText returns the active screen as fixed screen rows. Unlike
|
||||||
|
// PlainText, this preserves row boundaries so a host UI can repaint
|
||||||
|
// into a clipped viewport.
|
||||||
|
ScreenText() (string, error)
|
||||||
|
|
||||||
|
// SerializeVT returns the active screen as a VT byte sequence that, when
|
||||||
|
// written to a fresh terminal, reproduces the visible state (colours,
|
||||||
|
// styles, cursor, hyperlinks, etc.). Used as the daemon's "catch-up
|
||||||
|
// frame" for newly-attached clients.
|
||||||
|
SerializeVT() ([]byte, error)
|
||||||
|
|
||||||
|
// Cursor returns cursor position and visibility on the active screen.
|
||||||
|
Cursor() (CursorState, error)
|
||||||
|
|
||||||
|
// ActiveScreen reports whether we are on the primary or alternate buffer.
|
||||||
|
ActiveScreen() (Screen, error)
|
||||||
|
|
||||||
|
// OnWritePTY registers a callback that fires when the emulator wants
|
||||||
|
// to write bytes back to the PTY master (e.g. responses to DA / DSR
|
||||||
|
// queries). The callback runs synchronously inside Write and must not
|
||||||
|
// recurse into the emulator.
|
||||||
|
OnWritePTY(fn func([]byte))
|
||||||
|
|
||||||
|
// Close releases any underlying resources.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
390
internal/vt/ghostty.go
Normal file
390
internal/vt/ghostty.go
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
//go:build !nocgo
|
||||||
|
|
||||||
|
package vt
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -I${SRCDIR}/../../third_party/libghostty-vt/install/include -DGHOSTTY_STATIC
|
||||||
|
#cgo LDFLAGS: -L${SRCDIR}/../../third_party/libghostty-vt/install/lib -l:libghostty-vt.a -lm -lpthread
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <ghostty/vt.h>
|
||||||
|
|
||||||
|
// Forward declaration of the exported Go callback (defined in ghostty_cgo.go).
|
||||||
|
extern void pattermGhosttyWritePty(GhosttyTerminal terminal,
|
||||||
|
void *userdata,
|
||||||
|
const uint8_t *data,
|
||||||
|
size_t len);
|
||||||
|
|
||||||
|
// Constant device-attributes response. vim/htop/etc. send DA1 (CSI c) on
|
||||||
|
// startup and block waiting for a reply; without this they hang forever.
|
||||||
|
// Conformance 62 = VT220-class with no advertised features, which is what
|
||||||
|
// kitty advertises and is enough for every TUI we've tested.
|
||||||
|
static bool patterm_da_cb(GhosttyTerminal terminal,
|
||||||
|
void *userdata,
|
||||||
|
GhosttyDeviceAttributes *out) {
|
||||||
|
(void)terminal; (void)userdata;
|
||||||
|
out->primary.conformance_level = 62;
|
||||||
|
out->primary.num_features = 0;
|
||||||
|
out->secondary.device_type = 1; // VT220
|
||||||
|
out->secondary.firmware_version = 100; // arbitrary
|
||||||
|
out->secondary.rom_cartridge = 0;
|
||||||
|
out->tertiary.unit_id = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant XTVERSION response. Some agent TUIs query this; without a
|
||||||
|
// response they wait. The GhosttyString memory must stay valid until the
|
||||||
|
// callback returns — a static const string is fine.
|
||||||
|
static GhosttyString patterm_xtversion_cb(GhosttyTerminal terminal,
|
||||||
|
void *userdata) {
|
||||||
|
(void)terminal; (void)userdata;
|
||||||
|
static const char ver[] = "patterm 0.0.1";
|
||||||
|
GhosttyString s;
|
||||||
|
s.ptr = (const uint8_t *)ver;
|
||||||
|
s.len = sizeof(ver) - 1;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant ENQ response (empty). Some shells send ENQ on startup.
|
||||||
|
static GhosttyString patterm_enq_cb(GhosttyTerminal terminal, void *userdata) {
|
||||||
|
(void)terminal; (void)userdata;
|
||||||
|
GhosttyString s; s.ptr = NULL; s.len = 0;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers that hide casts cgo can't express directly.
|
||||||
|
|
||||||
|
static GhosttyResult patterm_install_write_pty(GhosttyTerminal t) {
|
||||||
|
return ghostty_terminal_set(t,
|
||||||
|
GHOSTTY_TERMINAL_OPT_WRITE_PTY,
|
||||||
|
(const void *)pattermGhosttyWritePty);
|
||||||
|
}
|
||||||
|
|
||||||
|
static GhosttyResult patterm_install_query_handlers(GhosttyTerminal t) {
|
||||||
|
GhosttyResult rc;
|
||||||
|
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES,
|
||||||
|
(const void *)patterm_da_cb);
|
||||||
|
if (rc != GHOSTTY_SUCCESS) return rc;
|
||||||
|
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_XTVERSION,
|
||||||
|
(const void *)patterm_xtversion_cb);
|
||||||
|
if (rc != GHOSTTY_SUCCESS) return rc;
|
||||||
|
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_ENQUIRY,
|
||||||
|
(const void *)patterm_enq_cb);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
static GhosttyResult patterm_set_userdata(GhosttyTerminal t, uintptr_t ud) {
|
||||||
|
return ghostty_terminal_set(t,
|
||||||
|
GHOSTTY_TERMINAL_OPT_USERDATA,
|
||||||
|
(const void *)ud);
|
||||||
|
}
|
||||||
|
|
||||||
|
static GhosttyFormatterTerminalOptions patterm_plain_fmt_opts(void) {
|
||||||
|
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
|
||||||
|
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
|
||||||
|
opts.unwrap = true;
|
||||||
|
opts.trim = true;
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
static GhosttyFormatterTerminalOptions patterm_screen_fmt_opts(void) {
|
||||||
|
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
|
||||||
|
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
|
||||||
|
opts.unwrap = false;
|
||||||
|
opts.trim = false;
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VT-format options for the daemon catch-up frame. Emits the active screen
|
||||||
|
// as VT escape sequences with cursor, style, hyperlink, mode, and tabstop
|
||||||
|
// state included so a freshly-attached client renders the existing screen
|
||||||
|
// correctly. unwrap/trim are NOT set — preserving wrap state and trailing
|
||||||
|
// cells is important for a faithful replay.
|
||||||
|
static GhosttyFormatterTerminalOptions patterm_vt_fmt_opts(void) {
|
||||||
|
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
|
||||||
|
opts.emit = GHOSTTY_FORMATTER_FORMAT_VT;
|
||||||
|
opts.extra.modes = true;
|
||||||
|
opts.extra.scrolling_region = true;
|
||||||
|
opts.extra.tabstops = true;
|
||||||
|
opts.extra.screen.cursor = true;
|
||||||
|
opts.extra.screen.style = true;
|
||||||
|
opts.extra.screen.hyperlink = true;
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"runtime/cgo"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GhosttyEmulator is the libghostty-vt-backed Emulator implementation.
|
||||||
|
//
|
||||||
|
// The C terminal handle is not thread-safe. Callers must serialise access;
|
||||||
|
// the spike CLI does this by running all calls on one goroutine, so the
|
||||||
|
// mutex below is a defensive belt-and-braces rather than the primary
|
||||||
|
// safety mechanism.
|
||||||
|
type GhosttyEmulator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
term C.GhosttyTerminal
|
||||||
|
handle cgo.Handle
|
||||||
|
closed bool
|
||||||
|
// onWrite is read from a cgo callback that is invoked synchronously
|
||||||
|
// from inside Write() — i.e. while e.mu is already held by this
|
||||||
|
// goroutine. Taking the mutex again would deadlock, so the field is
|
||||||
|
// stored atomically and read without the mutex.
|
||||||
|
onWrite atomic.Pointer[writeCallback]
|
||||||
|
cols uint16
|
||||||
|
rows uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCallback wraps the callback func so it can sit in atomic.Pointer.
|
||||||
|
type writeCallback struct{ fn func([]byte) }
|
||||||
|
|
||||||
|
// NewGhosttyEmulator creates a new emulator with the given grid size.
|
||||||
|
func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
|
||||||
|
if cols == 0 || rows == 0 {
|
||||||
|
return nil, fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
e := &GhosttyEmulator{cols: cols, rows: rows}
|
||||||
|
|
||||||
|
opts := C.GhosttyTerminalOptions{
|
||||||
|
cols: C.uint16_t(cols),
|
||||||
|
rows: C.uint16_t(rows),
|
||||||
|
max_scrollback: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc := C.ghostty_terminal_new(nil, &e.term, opts); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return nil, fmt.Errorf("vt: ghostty_terminal_new failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Park ourselves in cgo's handle table so the C callback can find us.
|
||||||
|
e.handle = cgo.NewHandle(e)
|
||||||
|
|
||||||
|
if rc := C.patterm_set_userdata(e.term, C.uintptr_t(uintptr(e.handle))); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
e.handle.Delete()
|
||||||
|
C.ghostty_terminal_free(e.term)
|
||||||
|
return nil, fmt.Errorf("vt: set userdata failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc := C.patterm_install_write_pty(e.term); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
e.handle.Delete()
|
||||||
|
C.ghostty_terminal_free(e.term)
|
||||||
|
return nil, fmt.Errorf("vt: install write_pty failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc := C.patterm_install_query_handlers(e.term); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
e.handle.Delete()
|
||||||
|
C.ghostty_terminal_free(e.term)
|
||||||
|
return nil, fmt.Errorf("vt: install query handlers failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure Close runs even if the caller forgets. Programs that hold
|
||||||
|
// the emulator for their full lifetime can ignore this.
|
||||||
|
runtime.SetFinalizer(e, func(x *GhosttyEmulator) { _ = x.Close() })
|
||||||
|
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) Write(p []byte) (int, error) {
|
||||||
|
if len(p) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.closed {
|
||||||
|
return 0, errors.New("vt: emulator closed")
|
||||||
|
}
|
||||||
|
C.ghostty_terminal_vt_write(
|
||||||
|
e.term,
|
||||||
|
(*C.uint8_t)(unsafe.Pointer(&p[0])),
|
||||||
|
C.size_t(len(p)),
|
||||||
|
)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) Resize(cols, rows uint16) error {
|
||||||
|
if cols == 0 || rows == 0 {
|
||||||
|
return fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows)
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.closed {
|
||||||
|
return errors.New("vt: emulator closed")
|
||||||
|
}
|
||||||
|
rc := C.ghostty_terminal_resize(e.term,
|
||||||
|
C.uint16_t(cols), C.uint16_t(rows),
|
||||||
|
0, 0, // pixel dimensions: we don't use image protocols in the spike
|
||||||
|
)
|
||||||
|
if rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return fmt.Errorf("vt: resize failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
e.cols, e.rows = cols, rows
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) PlainText() (string, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.closed {
|
||||||
|
return "", errors.New("vt: emulator closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := C.patterm_plain_fmt_opts()
|
||||||
|
return e.formatPlainLocked(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) ScreenText() (string, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.closed {
|
||||||
|
return "", errors.New("vt: emulator closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := C.patterm_screen_fmt_opts()
|
||||||
|
return e.formatPlainLocked(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) formatPlainLocked(opts C.GhosttyFormatterTerminalOptions) (string, error) {
|
||||||
|
var fmtr C.GhosttyFormatter
|
||||||
|
if rc := C.ghostty_formatter_terminal_new(nil, &fmtr, e.term, opts); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return "", fmt.Errorf("vt: formatter_terminal_new failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
defer C.ghostty_formatter_free(fmtr)
|
||||||
|
|
||||||
|
var buf *C.uint8_t
|
||||||
|
var n C.size_t
|
||||||
|
if rc := C.ghostty_formatter_format_alloc(fmtr, nil, &buf, &n); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return "", fmt.Errorf("vt: format_alloc failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
defer C.ghostty_free(nil, buf, n)
|
||||||
|
|
||||||
|
if buf == nil || n == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return C.GoStringN((*C.char)(unsafe.Pointer(buf)), C.int(n)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.closed {
|
||||||
|
return nil, errors.New("vt: emulator closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := C.patterm_vt_fmt_opts()
|
||||||
|
var fmtr C.GhosttyFormatter
|
||||||
|
if rc := C.ghostty_formatter_terminal_new(nil, &fmtr, e.term, opts); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return nil, fmt.Errorf("vt: formatter_terminal_new (vt) failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
defer C.ghostty_formatter_free(fmtr)
|
||||||
|
|
||||||
|
var buf *C.uint8_t
|
||||||
|
var n C.size_t
|
||||||
|
if rc := C.ghostty_formatter_format_alloc(fmtr, nil, &buf, &n); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return nil, fmt.Errorf("vt: format_alloc (vt) failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
defer C.ghostty_free(nil, buf, n)
|
||||||
|
|
||||||
|
if buf == nil || n == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return C.GoBytes(unsafe.Pointer(buf), C.int(n)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) Cursor() (CursorState, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.closed {
|
||||||
|
return CursorState{}, errors.New("vt: emulator closed")
|
||||||
|
}
|
||||||
|
var col, row C.uint16_t
|
||||||
|
var visible C.bool
|
||||||
|
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_X, unsafe.Pointer(&col)); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return CursorState{}, fmt.Errorf("vt: get cursor_x failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_Y, unsafe.Pointer(&row)); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return CursorState{}, fmt.Errorf("vt: get cursor_y failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE, unsafe.Pointer(&visible)); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return CursorState{}, fmt.Errorf("vt: get cursor_visible failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
return CursorState{Col: uint16(col), Row: uint16(row), Visible: bool(visible)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.closed {
|
||||||
|
return 0, errors.New("vt: emulator closed")
|
||||||
|
}
|
||||||
|
var s C.GhosttyTerminalScreen
|
||||||
|
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN, unsafe.Pointer(&s)); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return 0, fmt.Errorf("vt: get active_screen failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
if s == C.GHOSTTY_TERMINAL_SCREEN_ALTERNATE {
|
||||||
|
return ScreenAlternate, nil
|
||||||
|
}
|
||||||
|
return ScreenPrimary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {
|
||||||
|
if fn == nil {
|
||||||
|
e.onWrite.Store(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.onWrite.Store(&writeCallback{fn: fn})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePTYCallback is called from the exported cgo shim. It runs inside a
|
||||||
|
// vt_write() that already owns e.mu, so it MUST NOT take the mutex.
|
||||||
|
func (e *GhosttyEmulator) writePTYCallback() func([]byte) {
|
||||||
|
cb := e.onWrite.Load()
|
||||||
|
if cb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cb.fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) Close() error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
e.closed = true
|
||||||
|
runtime.SetFinalizer(e, nil)
|
||||||
|
C.ghostty_terminal_free(e.term)
|
||||||
|
e.term = nil
|
||||||
|
e.handle.Delete()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ghosttyResultStr(rc C.GhosttyResult) string {
|
||||||
|
switch rc {
|
||||||
|
case C.GHOSTTY_SUCCESS:
|
||||||
|
return "SUCCESS"
|
||||||
|
case C.GHOSTTY_OUT_OF_MEMORY:
|
||||||
|
return "OUT_OF_MEMORY"
|
||||||
|
case C.GHOSTTY_INVALID_VALUE:
|
||||||
|
return "INVALID_VALUE"
|
||||||
|
case C.GHOSTTY_OUT_OF_SPACE:
|
||||||
|
return "OUT_OF_SPACE"
|
||||||
|
case C.GHOSTTY_NO_VALUE:
|
||||||
|
return "NO_VALUE"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("unknown(%d)", int(rc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time assertion that GhosttyEmulator satisfies Emulator.
|
||||||
|
var _ Emulator = (*GhosttyEmulator)(nil)
|
||||||
38
internal/vt/ghostty_cgo.go
Normal file
38
internal/vt/ghostty_cgo.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//go:build !nocgo
|
||||||
|
|
||||||
|
package vt
|
||||||
|
|
||||||
|
/*
|
||||||
|
// This preamble must contain DECLARATIONS ONLY — cgo refuses to compile
|
||||||
|
// a file that both defines functions in its preamble and has //export
|
||||||
|
// directives. The helper definitions live in ghostty.go's preamble.
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <ghostty/vt.h>
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime/cgo"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
//export pattermGhosttyWritePty
|
||||||
|
func pattermGhosttyWritePty(_ C.GhosttyTerminal, userdata unsafe.Pointer, data *C.uint8_t, length C.size_t) {
|
||||||
|
if userdata == nil || data == nil || length == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h := cgo.Handle(uintptr(userdata))
|
||||||
|
v := h.Value()
|
||||||
|
e, ok := v.(*GhosttyEmulator)
|
||||||
|
if !ok || e == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cb := e.writePTYCallback()
|
||||||
|
if cb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := C.GoBytes(unsafe.Pointer(data), C.int(length))
|
||||||
|
cb(buf)
|
||||||
|
}
|
||||||
30
internal/vt/ghostty_nocgo.go
Normal file
30
internal/vt/ghostty_nocgo.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//go:build nocgo
|
||||||
|
|
||||||
|
// This file provides a stub GhosttyEmulator for `go vet` / `go build`
|
||||||
|
// invocations that pass the `nocgo` build tag, so the rest of the Go code can
|
||||||
|
// be checked without `libghostty-vt` being installed. The stub fails at
|
||||||
|
// construction time — there is no functional emulator in `nocgo` builds.
|
||||||
|
|
||||||
|
package vt
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
type GhosttyEmulator struct{}
|
||||||
|
|
||||||
|
func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
|
||||||
|
return nil, errors.New("vt: built with -tags nocgo; libghostty-vt is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub }
|
||||||
|
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub }
|
||||||
|
func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub }
|
||||||
|
func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub }
|
||||||
|
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }
|
||||||
|
func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub }
|
||||||
|
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub }
|
||||||
|
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {}
|
||||||
|
func (e *GhosttyEmulator) Close() error { return nil }
|
||||||
|
|
||||||
|
var errStub = errors.New("vt: built with -tags nocgo")
|
||||||
|
|
||||||
|
var _ Emulator = (*GhosttyEmulator)(nil)
|
||||||
1
third_party/libghostty-vt/COMMIT
vendored
Normal file
1
third_party/libghostty-vt/COMMIT
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
b0f8276658fbcc75318d2125d40146074a3fc505
|
||||||
67
third_party/libghostty-vt/README.md
vendored
Normal file
67
third_party/libghostty-vt/README.md
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Vendored libghostty-vt
|
||||||
|
|
||||||
|
This directory holds a pinned checkout of [libghostty-vt](https://libghostty.tip.ghostty.org/),
|
||||||
|
the headless VT emulator extracted from [Ghostty](https://github.com/ghostty-org/ghostty).
|
||||||
|
|
||||||
|
## Pin
|
||||||
|
|
||||||
|
- Repo: <https://github.com/ghostty-org/ghostty>
|
||||||
|
- Branch: `main`
|
||||||
|
- Commit: see `./COMMIT` — pinned to a specific SHA, not a tag.
|
||||||
|
|
||||||
|
We initially tried tag `v1.3.1` (`332b2aefc6...`) but the `terminal.h` and `formatter.h`
|
||||||
|
headers we need had not yet been added to the public API at that tag. The pin is to a
|
||||||
|
later commit on `main` where the full libghostty-vt surface (terminal + formatter +
|
||||||
|
encoders) is in `include/ghostty/vt/`.
|
||||||
|
|
||||||
|
The upstream API is explicitly unstable. Do not bump the pin casually — re-run the spike's
|
||||||
|
test matrix when you do.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
This directory is empty after `git clone` of `patterm`. The vendored source is fetched
|
||||||
|
on demand by the project's `Makefile`:
|
||||||
|
|
||||||
|
```
|
||||||
|
make deps # clones the pinned ghostty source into ./source/
|
||||||
|
# builds it via zig and installs into ./install/
|
||||||
|
```
|
||||||
|
|
||||||
|
After `make deps` the layout is:
|
||||||
|
|
||||||
|
```
|
||||||
|
third_party/libghostty-vt/
|
||||||
|
├── COMMIT # the pinned SHA, source of truth
|
||||||
|
├── README.md
|
||||||
|
├── source/ # shallow clone of ghostty-org/ghostty at $(cat COMMIT)
|
||||||
|
└── install/ # build output (consumed by cgo)
|
||||||
|
├── include/ghostty/...
|
||||||
|
├── lib/libghostty-vt.a
|
||||||
|
└── lib/libghostty-vt.so
|
||||||
|
```
|
||||||
|
|
||||||
|
`source/` and `install/` are gitignored.
|
||||||
|
|
||||||
|
## Build prerequisites
|
||||||
|
|
||||||
|
- `zig` ≥ 0.15.2 on `$PATH`. Arch Linux currently ships 0.16.0, which is newer than the
|
||||||
|
declared `minimum_zig_version` in upstream `build.zig.zon`. If zig refuses to build
|
||||||
|
with the installed version, install the matching toolchain via
|
||||||
|
[zigup](https://github.com/marler8997/zigup) or your distro's archive.
|
||||||
|
- A C toolchain for cgo (`gcc` or `clang`).
|
||||||
|
- `git` and `curl`.
|
||||||
|
|
||||||
|
## What we use from this library
|
||||||
|
|
||||||
|
The spike (`cmd/spike`) only touches a small surface:
|
||||||
|
|
||||||
|
- `ghostty_terminal_new` / `ghostty_terminal_free`
|
||||||
|
- `ghostty_terminal_vt_write` (feed PTY bytes in)
|
||||||
|
- `ghostty_terminal_resize` (on SIGWINCH)
|
||||||
|
- `ghostty_terminal_set` to install a `WRITE_PTY` callback (DA queries etc.)
|
||||||
|
- `ghostty_terminal_get` for `ACTIVE_SCREEN`, `CURSOR_X`, `CURSOR_Y`, `CURSOR_VISIBLE`
|
||||||
|
- `ghostty_formatter_terminal_new` + `ghostty_formatter_format_alloc` with
|
||||||
|
`GHOSTTY_FORMATTER_FORMAT_PLAIN`, `unwrap=true`, `trim=true`
|
||||||
|
|
||||||
|
Everything else (Kitty graphics, OSC parsing, render state, key/mouse encoders) is
|
||||||
|
deferred to later milestones.
|
||||||
Reference in New Issue
Block a user