# Ghost Overlay

> For agents: start with the [agent guide](https://pi-tui.ratstack.sh/llms.txt). Every page here has a Markdown twin, and every pattern has a JSON record.

Behavioral / Keys and focus · `ghost-overlay` · [HTML](https://pi-tui.ratstack.sh/patterns/ghost-overlay/) · [JSON](https://pi-tui.ratstack.sh/patterns/ghost-overlay.json) · [all patterns](https://pi-tui.ratstack.sh/patterns.md)

Also known as Passive overlay.

## Intent

Keep an overlay visible without automatically taking keyboard focus.

## Motivation

Nico's upstream non-capturing overlay work lets visible auxiliary content leave keyboard input with its underlying component.

## Applicability

- Use this when auxiliary content should not interrupt the underlying editor.

## Structure

```text
nonCapturing overlay -> visible
keyboard -> base component
focus() -> overlay
```

## Participants

- [`ctx.ui.custom`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays): Mounts one interaction and resolves through done.
- [`OverlayOptions`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays): Defines overlay dimensions, placement and focus behavior.
- [`OverlayHandle.focus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays): Gives the overlay keyboard ownership.
- [`OverlayHandle.unfocus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays): Releases input to fallback or a specified target.
- `Underlying editor`: Keeps input until the overlay explicitly takes focus.

Pi screen APIs: [`ctx.ui.custom`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays), [`OverlayOptions`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays), [`OverlayHandle.focus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays), [`OverlayHandle.unfocus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays), `custom`, `setStatus`, `setWidget`, `Box`, `DynamicBorder`, `OverlayOptions.nonCapturing`, `CustomEditor`

## Consequences

- A passive view does not interrupt editor input.
- Interaction requires an explicit later focus transfer.

## Implementation

- Use nonCapturing explicitly.
- Request focus only when the auxiliary view needs interaction.

## States

Each state was rendered at 40, 60, 80 and 120 columns in Pi's dark and light themes. Each frame links one WebP. An animated frame links its first image, then the animation.

### Editor keeps focus

`passive`: Show a passive synthetic panel while typing in the host editor.

Checks at every width and theme:

- width: ✓ pass
- style-leak: ✓ pass
- hard-coded-colour: ✓ pass
- height: ✓ pass

Frames:

- 40 columns: ![Ghost Overlay, Editor keeps focus, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/passive-40-dark.8e850a91972c.webp) ![Ghost Overlay, Editor keeps focus, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/passive-40-light.cc39d9cc8bbb.webp)
- 60 columns: ![Ghost Overlay, Editor keeps focus, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/passive-60-dark.4155e8706c87.webp) ![Ghost Overlay, Editor keeps focus, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/passive-60-light.77ada60b0d08.webp)
- 80 columns: ![Ghost Overlay, Editor keeps focus, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/passive-80-dark.94bc555890d7.webp) ![Ghost Overlay, Editor keeps focus, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/passive-80-light.9a225aef2adc.webp)
- 120 columns: ![Ghost Overlay, Editor keeps focus, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/passive-120-dark.54826059fda5.webp) ![Ghost Overlay, Editor keeps focus, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/passive-120-light.c1666f515181.webp)

### Explicit focus

`active`: Focus the same panel for one interaction.

Checks at every width and theme:

- width: ✓ pass
- style-leak: ✓ pass
- hard-coded-colour: ✓ pass
- height: ✓ pass

Frames:

- 40 columns: ![Ghost Overlay, Explicit focus, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/active-40-dark.8930c2fa229d.webp) ![Ghost Overlay, Explicit focus, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/active-40-light.7b43a0ed981b.webp)
- 60 columns: ![Ghost Overlay, Explicit focus, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/active-60-dark.357a520467d6.webp) ![Ghost Overlay, Explicit focus, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/active-60-light.307e0fad1cad.webp)
- 80 columns: ![Ghost Overlay, Explicit focus, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/active-80-dark.c7984a18c1e9.webp) ![Ghost Overlay, Explicit focus, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/active-80-light.d43fc5c3764c.webp)
- 120 columns: ![Ghost Overlay, Explicit focus, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/active-120-dark.3e38812b0b35.webp) ![Ghost Overlay, Explicit focus, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/active-120-light.3d47f7de5520.webp)

### Focus released

`released`: Release input ownership while leaving the panel visible.

Checks at every width and theme:

- width: ✓ pass
- style-leak: ✓ pass
- hard-coded-colour: ✓ pass
- height: ✓ pass

Frames:

- 40 columns: ![Ghost Overlay, Focus released, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/released-40-dark.b1cc0094a7ba.webp) ![Ghost Overlay, Focus released, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/released-40-light.2485d8dc804c.webp)
- 60 columns: ![Ghost Overlay, Focus released, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/released-60-dark.8fb8128f921c.webp) ![Ghost Overlay, Focus released, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/released-60-light.53256be6744a.webp)
- 80 columns: ![Ghost Overlay, Focus released, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/released-80-dark.d6953923d123.webp) ![Ghost Overlay, Focus released, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/released-80-light.6e84cca9de51.webp)
- 120 columns: ![Ghost Overlay, Focus released, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/released-120-dark.f9ab1c6f4033.webp) ![Ghost Overlay, Focus released, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/ghost-overlay/released-120-light.e4b853f31fc0.webp)

## Sample code

`stories/patterns/behavioral/ghost-overlay.ts`, the story the frames above were rendered from.

```ts
import { Box, Text, type OverlayHandle } from "@earendil-works/pi-tui";
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
import type { PatternStory } from "../../../src/pattern.ts";

// Original example on Pi 1.0.3. No extension source is executed.
export const story: PatternStory = {
  id: "ghost-overlay",
  title: "Ghost Overlay",
  kind: "screen",
  apis: ["custom", "setStatus", "setWidget", "Box", "DynamicBorder", "OverlayOptions.nonCapturing", "OverlayHandle.focus", "OverlayHandle.unfocus", "CustomEditor"],
  states: [
    { id: "passive", label: "Editor keeps focus", steps: [{ type: "text", text: "Draft a small task list" }] },
    { id: "active", label: "Explicit focus", steps: [{ type: "action", name: "focus" }, { type: "keys", data: "x" }] },
    { id: "released", label: "Focus released", steps: [{ type: "action", name: "release" }, { type: "text", text: " next" }] },
  ],
  setup({ ui, action, theme, screen }) {
    let handle: OverlayHandle | undefined;
    let active = false, interactions = 0;
    const label = new Text("", 0, 0);
    const update = () => {
      const mode = active ? "active" : "passive";
      label.setText(theme.fg("accent", "🐀 Task preview · " + mode) + "\n" +
        theme.fg("text", "3 tasks · " + interactions + " panel input" + (interactions === 1 ? "" : "s")));
      ui.setStatus("preview", theme.fg(active ? "accent" : "success", mode));
    };
    ui.setWidget("hint", [theme.fg("muted", "Task preview leaves the draft alone.")], { placement: "aboveEditor" });
    ui.setWidget("below", [theme.fg("dim", "↑↓ history · enter submit")], { placement: "belowEditor" });
    ui.setEditorText("");
    // A theme-backed Box separates the panel from the host. Pi borders frame
    // it in both themes; padding belongs to the panel, not the base screen.
    const panel = new Box(1, 0, line => theme.bg("customMessageBg", line));
    panel.addChild(new DynamicBorder(line => theme.fg("borderAccent", line)));
    panel.addChild(label);
    panel.addChild(new DynamicBorder(line => theme.fg("borderAccent", line)));
    const view = {
      render: (width: number) => panel.render(width),
      invalidate: () => panel.invalidate(),
      handleInput: () => { interactions++; update(); },
    };
    update();
    void ui.custom(() => view, {
      overlay: true,
      overlayOptions: { nonCapturing: true, width: "75%", anchor: "top-right", row: 1, margin: 1 },
      onHandle: value => { handle = value; },
    });
    action("focus", () => { if (!handle) throw new Error("Preview not mounted"); handle.focus(); active = true; update(); });
    action("release", () => {
      if (!handle) throw new Error("Preview not mounted");
      handle.unfocus({ target: screen.editor }); active = false; update();
    });
  },
};
```

## Known uses: seen in Nico's repos

- [**earendil-works/pi**](https://github.com/earendil-works/pi): Non-capturing overlays with explicit focus control
  - [packages/tui/src/tui.ts:114-170](https://github.com/earendil-works/pi/blob/841c95ac9c7372b8f578c86d053d3b03b9ce2f20/packages/tui/src/tui.ts#L114-L170) @841c95ac
  - [packages/tui/src/tui.ts:186-228](https://github.com/earendil-works/pi/blob/735ccbd00ff6ce091dd6505f2d07c265b78a9092/packages/tui/src/tui.ts#L186-L228) @735ccbd0
- [**pi-interactive-shell**](https://github.com/nicobailon/pi-interactive-shell): Non-capturing overlays with explicit focus control
- [**pi-side-chat**](https://github.com/nicobailon/pi-side-chat): Non-capturing overlays with explicit focus control

## For agents: choose and check

Choose Ghost Overlay when your job matches its intent and applicability above. Its neighbours in Keys and focus are listed below. Read the one whose intent fits your job more closely before you commit.

- [Action Compass](https://pi-tui.ratstack.sh/patterns/action-compass.md): Resolve configurable actions through injected keybindings.
- [Input Switch](https://pi-tui.ratstack.sh/patterns/input-switch.md): Continue, transform or handle submitted input before normal processing.
- [Focus Baton](https://pi-tui.ratstack.sh/patterns/focus-baton.md): Move keyboard ownership without closing a persistent overlay.
- [Field Baton](https://pi-tui.ratstack.sh/patterns/field-baton.md): Switch keyboard focus between a choice list and an editable task field.
- [Control Baton](https://pi-tui.ratstack.sh/patterns/control-baton.md): Pause automatic output updates while the user controls an interactive job.
- [Pause Latch](https://pi-tui.ratstack.sh/patterns/pause-latch.md): Keep pause, resume and quit controls inside the component input contract.
- [Input Lease](https://pi-tui.ratstack.sh/patterns/input-lease.md): Intercept raw terminal controls only while their owning operation is active.

Check your version:

- Render your version at 40, 60, 80 and 120 columns in the dark and light themes.
- Check width: no rendered line is wider than the terminal.
- Check style-leak: no line ends with colour, bold or a link still switched on.
- Check hard-coded-colour: every colour on screen comes from the active theme.
- Check height: the output fits in the rows the terminal has.
- Read the Pi 1.0.3 docs for [`ctx.ui.custom`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays), [`OverlayOptions`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays), [`OverlayHandle.focus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays), [`OverlayHandle.unfocus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#use-custom-screens-and-overlays), `custom`, `setStatus`, `setWidget`, `Box`, `DynamicBorder`, `OverlayOptions.nonCapturing`, `CustomEditor` before you use them.
- Every reference frame gets the verdict its state expects.

Next actions: `related({ id: "ghost-overlay" })` lists what to read next, and `states({ id: "ghost-overlay", width: 40 })` returns every frame and verdict at one width. The [build-pi-tui-component skill](https://pi-tui.ratstack.sh/skills/build-pi-tui-component.md) walks through the whole loop.

## Related

- [Focus Baton](https://pi-tui.ratstack.sh/patterns/focus-baton.md): transfers input after mounting

## Linked from

- [Focus Baton](https://pi-tui.ratstack.sh/patterns/focus-baton.md): starts without taking focus
