<?xml version="1.0" encoding="UTF-8"?>
<spec xmlns="https://vibevm.org/spec/1">
  <title id="root">PROP-042 — AIUI observation: the render plane &amp; the `vibe aiui` surface</title>
  <status stage="spec" state="done" comment="B0 2026-07-24: ACTIVE v0.1, 2026-07-16; fact grain 2026-07-24"/>
  <p p="1"><fact id="status-line" status="spec/done">**Status:** ACTIVE (v0.1, 2026-07-16). **Module:** `vibe-cli`.
**Related:** PROP-037 (the `vibe tree` TUI it observes), PROP-039 §11.3 (the model
plane / `vibe-actions::aiui`), PROP-036 (the tree model). The terminal products
(vibeterm, vibeframe) and their contracts now live in the `vibevm-term` products
repo — this PROP cites them as cross-repo contracts
(`spec://vibeterm/*`, `spec://term-common/*`); the `vibe aiui` / `vibe term`
CLI surface itself stays on the host.</fact></p>
  <list ordered="false" p="2">
    <item><fact id="PROP-SCOPE" status="spec/done">This contract governs the **render plane** — a terminal-free way to render the
  `vibe tree` TUI to a symbolic snapshot so an agent (or a golden test) can *see*
  the interface without a real terminal — and the `vibe aiui` CLI surface that
  exposes it.</fact></item>
    <item><fact id="other-planes" status="spec/done">The terminal plane (vibeterm) and the model plane are governed
  elsewhere (a vibeterm PROP in `vibevm-term` / PROP-039).</fact></item>
  </list>
  <section id="render-plane" title="1. The render plane">
    <list ordered="false" p="3">
      <item><fact id="HEADLESS-RENDER" status="impl/done">REQ. The TUI renders **headlessly**: given a built `PackageTree`, a terminal size
  `cols×rows`, and an optional **key script** (§3), the surface drives the real
  input + render path — `input::handle` for each scripted key, then `render::draw`
  into an off-screen `ratatui::Buffer` — and returns that Buffer.</fact></item>
      <item><fact id="NO-TERMINAL" status="impl/done">No terminal, no alternate screen, no raw mode, no `rat-salsa` loop; the
  entrypoint is a pure function of `(tree, size, script)`.</fact></item>
    </list>
    <list ordered="false" p="4">
      <item><fact id="RENDER-DETERMINISTIC" status="impl/done">REQ. The headless render is **deterministic**: it uses the built-in theme
  defaults (the canonical Rosé Pine palette, Tier 3 — §PROP-037 §2.2) and never
  loads user settings from disk, so the same `(tree, size, script)` always yields
  the same Buffer.</fact></item>
      <item><fact id="SNAPSHOT-PINNING" status="impl/done">Snapshot callers pin `tree` (a fixture), `size`, and `script`.</fact></item>
    </list>
    <list ordered="false" p="5">
      <item><fact id="SIDE-EFFECT-KEYS-REFUSED" status="impl/done" action="continue" actionstage="doc" audience="agent">REQ. A scripted key that would **escape the process or mutate the world** is
  refused, not executed: `F4` (spawns the settings subprocess) and `F6`/`Shift+F6`
  (write the clipboard) are rejected by the key-script parser (§3).</fact></item>
      <item><fact id="OBSERVES-NOT-ACTS" status="impl/done">The render plane observes; it does not act outside the model.</fact></item>
    </list>
  </section>
  <section id="snapshot-contract" title="2. The snapshot contract">
    <p p="6"><fact id="SNAPSHOT-FORMATS" status="impl/done" action="continue" actionstage="doc" audience="agent">REQ. A rendered Buffer projects to one of two **snapshot formats**, the same
schema every observation plane emits:</fact></p>
    <list ordered="false" p="7">
      <item><fact id="FMT-TEXT" status="impl/done">**`text`** — the glyph grid: one line per row, each row the concatenation of
  the cells' symbols with trailing whitespace trimmed. The golden-file form
  (committed `.snap.txt`, re-rendered and diffed).</fact></item>
      <item><fact id="FMT-CELLS" status="impl/done" action="continue" actionstage="doc" audience="agent">**`cells`** — JSON: `{cols, rows, rows:[[run,…],…]}` where each **run** is
  `{n, ch, fg?, bg?, mods?}` — `n` cells of glyph `ch` sharing a style, run-length
  encoded per row; `fg`/`bg` are `#rrggbb` (or an ANSI role name), `mods` the set
  of `bold`/`dim`/`italic`/`underlined`/`reversed` present. Enables style/colour
  assertions (e.g. "the active group's border run is the accent colour").</fact></item>
    </list>
    <list ordered="false" p="8">
      <item><fact id="FMT-LOSSLESS" status="impl/done">REQ. `text` is **lossless for layout** (every cell's glyph, in grid order) and
  `cells` is **lossless for style**; neither invents content.</fact></item>
      <item><fact id="BLANK-TRIM" status="impl/done">A blank cell is a
  space; the trim is per-row and right-only, so column alignment within a row is
  preserved.</fact></item>
    </list>
  </section>
  <section id="key-script" title="3. The key script">
    <list ordered="false" p="9">
      <item><fact id="KEY-SCRIPT-GRAMMAR" status="impl/done" action="continue" actionstage="doc" audience="agent">REQ. A **key script** is a space-separated list of key names driving the TUI
  before the snapshot. The grammar: function keys `F1`–`F12`; navigation `Up`,
  `Down`, `Left`, `Right`; `Enter`, `Esc`, `Tab`, `BackTab`, `Space`, `Backspace`;
  a `Shift+` prefix on any of them (e.g. `Shift+Left`, `Shift+Tab` ≡ `BackTab`).
  Names are case-insensitive.</fact></item>
      <item><fact id="UNKNOWN-KEY-ERROR" status="impl/done">An unknown name, or a refused side-effecting key
  (`F4`, `F6`; §1), is a hard error naming the offending token — never a silent
  skip.</fact></item>
    </list>
    <list ordered="false" p="10">
      <item><fact id="RENDER-PLANE-EVENTS" status="impl/done">REQ. The **render plane** (§1) turns each key name straight into a
  `crossterm::event::Event` — terminal-free, no escape bytes.</fact></item>
      <item><fact id="TERMINAL-PLANE-ENCODING" status="impl/done">The **terminal
  plane** (§4, `vibe aiui send`) must instead encode each name to the bytes the
  hosted program's platform expects, and the encoding is **platform-specific**: on
  Unix, the standard xterm VT sequences (SS3 `ESC O P`–`S` for F1–F4, CSI for the
  rest); on **Windows**, **win32-input-mode** (`ESC [ Vk;Sc;Uc;Kd;Cs;Rc _` — a
  key-down record then a key-up), the form a ConPTY translates into the console
  `INPUT_RECORD`s a raw reader expects.</fact></item>
      <item><fact id="VT-UNRELIABLE-WINDOWS" status="impl/done">The raw VT form is **not** reliable on
  Windows: conhost synthesises a key record from it for a cooked reader (a shell)
  but not for a raw reader (a crossterm TUI such as `vibe tree`), so the keys are
  silently dropped.</fact></item>
      <item><fact id="SCRIPT-PLANE-IDENTICAL" status="impl/done">A caller therefore drives the same key script identically on
  either plane; the encoding difference is the implementation's to hide.</fact></item>
    </list>
  </section>
  <section id="aiui-cli" title="4. The `vibe aiui` surface">
    <p p="11"><fact id="AIUI-FAMILY" status="impl/done" action="continue" actionstage="doc" audience="agent">REQ. `vibe aiui` is the agent-facing command family. Its render-plane verb:</fact></p>
    <fence p="12">vibe aiui render [--path &lt;dir&gt;] [--size &lt;COLSxROWS&gt;] [--send "&lt;script&gt;"] [--format text|cells]</fence>
    <list ordered="false" p="13">
      <item><fact id="RENDER-VERB-SEMANTICS" status="impl/done" action="continue" actionstage="doc" audience="agent">builds the `vibe tree` model at `--path` (the same resolver `vibe tree` uses),
  drives `--send` (§3) at `--size` (default `80x24`), and prints the `--format`
  snapshot (§2, default `text`) to stdout.</fact></item>
      <item><fact id="RENDER-READ-ONLY" status="impl/done">It is read-only and non-interactive:
  it never enters the TUI, spawns a terminal, or touches user state.</fact></item>
    </list>
    <p p="14"><fact id="TERMINAL-VERBS" status="impl/done" action="continue" actionstage="doc" audience="agent">REQ. The **terminal-plane** verbs drive a live vibeterm control session:</fact></p>
    <fence p="15">vibe aiui open     [--exec &lt;cmd&gt;] [--size &lt;COLSxROWS&gt;] [--timeout-ms &lt;n&gt;]
vibe aiui send     &lt;key&gt;... [--text &lt;literal&gt;] [--session &lt;pid&gt;]
vibe aiui snapshot [--session &lt;pid&gt;]
vibe aiui wait     [--idle-ms &lt;n&gt;] [--timeout-ms &lt;n&gt;] [--session &lt;pid&gt;]
vibe aiui close    [--session &lt;pid&gt;]
vibe aiui inspect   &lt;expr&gt; [--session &lt;pid&gt;]
vibe aiui pty-stop  [--session &lt;pid&gt;]
vibe aiui pty-start [--session &lt;pid&gt;]
vibe aiui scrollbar &lt;auto|on|off&gt; [--session &lt;pid&gt;]</fence>
    <list ordered="false" p="16">
      <item><fact id="VERB-OPEN" status="impl/done">`open` launches a **windowless** vibeterm running `--exec` (default: the console
  `vibe tree` over the current directory) with a control server, waits for its
  discovery file, and prints the session id (the vibeterm pid).</fact></item>
      <item><fact id="VERB-SEND" status="impl/done">`send` drives a key script (§3) and/or literal `--text`.</fact></item>
      <item><fact id="VERB-SNAPSHOT" status="impl/done">`snapshot` prints the live grid (§2).</fact></item>
      <item><fact id="VERB-WAIT" status="impl/done">`wait` blocks until the hosted program has answered the last input **and** the
  grid has settled (deterministic snapshots — never the pre-key screen).</fact></item>
      <item><fact id="VERB-CLOSE" status="impl/done">`close` tears the session down.</fact></item>
      <item><fact id="VERB-INSPECT" status="impl/done">`inspect` evaluates a JavaScript expression in the live renderer page over
  CDP and prints its return value as JSON — the agent reads the renderer's **real** runtime
  state (the xterm grid's cols and cell metrics, the scrollbar box) instead of inferring it
  from a snapshot. Requires a `--control` session.</fact></item>
      <item><fact id="VERB-PTY-STOP" status="impl/done">`pty-stop` stops the hosted program — the PTY child — **without** restarting
  Electron: the renderer, the CDP endpoint and the discovery file all stay live, so the
  program's binary is freed for a rebuild while the session survives.</fact></item>
      <item><fact id="VERB-PTY-START" status="impl/done">`pty-start` (re)spawns the hosted program at the current grid. Paired with
  `pty-stop` around a rebuild it is the fast TUI-preview loop: the agent sees the change
  without reconnecting CDP or relaunching Electron.</fact></item>
      <item><fact id="VERB-SCROLLBAR" status="impl/done">`scrollbar` sets the scrollbar policy live — `auto` (hidden for a
  full-screen TUI, shown for a shell), `on` (always), `off` (never). The renderer refits the
  grid; no Electron restart. Requires a `--control` session.</fact></item>
      <item><fact id="SESSION-DEFAULT" status="impl/done">A verb defaults to the most recent session; `--session &lt;pid&gt;`
  targets a specific one.</fact></item>
    </list>
    <p p="17"><fact id="MODEL-VERB" status="impl/done" action="continue" actionstage="doc" audience="agent">REQ. The **model-plane** verb projects the TUI state — no rendering at all:</fact></p>
    <fence p="18">vibe aiui state [--path &lt;dir&gt;] [--send "&lt;script&gt;"]</fence>
    <list ordered="false" p="19">
      <item><fact id="STATE-SEMANTICS" status="impl/done">builds the `vibe tree` model at `--path`, drives `--send` (§3), and prints a
  serialisable `ModelView` (PROP-039 §11.2/§11.3) — display mode, ordering, the
  active tab, the selection, the visible rows, and which modals are open.</fact></item>
      <item><fact id="STATE-READ-ONLY" status="impl/done">It is
  read-only and non-interactive; it observes structured state an agent asserts on
  (flow, focus, open menus), never pixels.</fact></item>
      <item><fact id="STATE-PURE" status="impl/done">The projection is a pure function of
  the built `App` and carries no rendering types.</fact></item>
    </list>
    <list ordered="false" p="20">
      <item><fact id="CONTROL-LOOPBACK" status="impl/done">REQ. The control transport is **loopback-only and token-guarded**. A `--control`
  vibeterm serves JSON over `http://127.0.0.1:&lt;ephemeral&gt;`.</fact></item>
      <item><fact id="DISCOVERY-FILES" status="impl/done">It writes a discovery
  file `~/.vibe/aiui/&lt;pid&gt;.json` plus a `latest.json` pointer, each
  `{ port, token, pid, startedAt }` at mode `0600`.</fact></item>
      <item><fact id="BEARER-TOKEN" status="impl/done">Every request carries the
  bearer token; the socket binds `127.0.0.1` only.</fact></item>
      <item><fact id="STALE-SESSION-GUARD" status="impl/done">`open` accepts a discovered
  session only when its `startedAt` is at or after the spawn instant, so a stale
  `latest.json` is never mistaken for the freshly-spawned one.</fact></item>
      <item><fact id="state-governance" status="spec/done">The model-plane `state` verb is governed by PROP-039 §11.2/§11.3; its `vibe tree`
  projection is prototyped here per PROP-039 §13 (the TUI is the reference surface).</fact></item>
    </list>
  </section>
  <section id="vibe-term" title="5. The `vibe term` launcher">
    <list ordered="false" p="21">
      <item><fact id="TERM-LAUNCHER" status="impl/done" action="continue" actionstage="doc" audience="user">REQ. `vibe term` launches the **vibeterm** terminal app hosting an interactive
  shell, so the terminal can be used and eyeball-debugged standalone.</fact></item>
      <item><fact id="SHELL-DETECTION" status="impl/done">The shell is
  **detected**: on Windows, modern PowerShell 7+ (`pwsh`) is preferred over the
  built-in Windows PowerShell 5.1 — resolved via the standard install locations
  (`%ProgramFiles%\PowerShell\7\pwsh.exe`, `%LOCALAPPDATA%\…\WindowsApps\pwsh.exe`)
  then `PATH`, falling back to `…\WindowsPowerShell\v1.0\powershell.exe`; on other
  platforms `$SHELL`, falling back to `/bin/sh`.</fact></item>
      <item><fact id="EXEC-OVERRIDE" status="impl/done">An explicit `--exec &lt;cmd&gt;`
  overrides the detected shell.</fact></item>
    </list>
    <p p="22"><fact id="APP-RESOLUTION" status="impl/done">REQ. The terminal app (`vibeterm` for `vibe term`, `vibeframe` for `vibe tree
-t`) is located in three tiers in order:</fact></p>
    <list ordered="true" p="23">
      <item><fact id="TIER-ENV" status="impl/done">an explicit `$VIBEVM_&lt;APP&gt;`
   directory wins (`&lt;APP&gt;` is the uppercased app name — `VIBETERM` / `VIBEFRAME`,
   an override a developer or a launcher sets);</fact></item>
      <item><fact id="TIER-PACKAGED-INSTANCE" status="impl/done">else an installed `vibe`
   checks the packaged `&lt;app&gt;/` shipped inside its own instance dir, next to its
   binary (the legacy, pre-extraction layout — kept for back-compat with instances
   that still carry it);</fact></item>
      <item><fact id="TIER-PATH" status="impl/done">else a `PATH` lookup for the app-named packaged
   binary (`vibeterm` / `vibeframe`) — the **extracted-product path**, how the
   vibevm-term repo's `&lt;app&gt; self install` publishes the product. The directory the
   binary sits in is treated as the packaged root.</fact></item>
    </list>
    <list ordered="false" p="24">
      <item><fact id="PACKAGED-VS-DEV" status="impl/done">The resolver distinguishes a
  **packaged** dir (electron binary at its root, `resources/app/` inside — invoked
  directly, no app-path arg) from a **dev** dir (Electron resolved via
  `node_modules/electron/path.txt`, the app dir passed as a positional arg — only
  reachable through `$VIBEVM_&lt;APP&gt;` now that the in-tree `apps/` source has moved
  to vibevm-term).</fact></item>
      <item><fact id="RESOLUTION-FAILURE" status="impl/done">Resolution failure returns a typed error; `vibe term` /
  `vibe frame` surface it to the user, while `vibe tree` falls back to running
  the console TUI in place (§5.1).</fact></item>
    </list>
    <section id="in-place-upgrade" title="5.1 In-place upgrade &amp; the icon protocol">
      <list ordered="false" p="25">
        <item><fact id="VIBETERM-ENV" status="impl/done">REQ. vibeterm sets `VIBETERM=1` in its PTY environment.</fact></item>
        <item><fact id="IN-PLACE-UPGRADE" status="impl/done" action="continue" actionstage="doc" audience="user">A `vibe tree` launched
  **inside** vibeterm (this env present) does not spawn a second window — it
  upgrades the current terminal in place: the `-t` / vibeterm launch resolves to
  the in-terminal console TUI here, so a plain shell becomes a "VibeTree terminal"
  for the session (PROP-036 §2.13). Outside vibeterm, `-t` still opens the desktop
  app.</fact></item>
      </list>
      <list ordered="false" p="26">
        <item><fact id="ICON-SWAP" status="impl/done">REQ. While the tree is open in that upgrade, vibeterm's **window + taskbar icon**
  is swapped to `vibetree`, reverting to the window's launch icon on exit. The swap
  is in-band: `vibe tree` emits `OSC 7773 ; &lt;icon-name&gt; ST` (an empty name reverts);
  the vibeterm renderer forwards the name to the main process, which calls
  `win.setIcon`.</fact></item>
        <item><fact id="ICON-PLATFORM-GAP" status="impl/done">**Windows + Linux only** — `setIcon` is a no-op on macOS (the app
  owns its Dock icon there), the documented platform gap.</fact></item>
        <item><fact id="OSC-DISCARDED" status="impl/done">In any non-vibeterm
  terminal the OSC is an unknown sequence, harmlessly discarded.</fact></item>
      </list>
    </section>
  </section>
  <section id="never" title="6. Never">
    <list ordered="false" p="27">
      <item><fact id="NEVER-USER-SETTINGS" status="impl/done">Never load user settings into a snapshot render — determinism dies and goldens
  churn. Defaults only.</fact></item>
      <item><fact id="NEVER-SIDE-EFFECT-KEYS" status="impl/done">Never execute a side-effecting key (`F4`/`F6`) in the render plane.</fact></item>
      <item><fact id="NEVER-INVENT-CONTENT" status="impl/done">Never let a snapshot format invent or drop content — `text` is every glyph in
  grid order; `cells` is every run with its true style.</fact></item>
      <item><fact id="NEVER-INTERACTIVE" status="impl/done">Never enter the interactive TUI from `vibe aiui` — it is headless by contract.</fact></item>
    </list>
  </section>
</spec>
