# Input Lease

> 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.

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

Also known as Temporary input listener.

## Intent

Intercept raw terminal controls only while their owning operation is active.

## Motivation

Foreground progress controls and fleet widgets must remove terminal listeners after their operation ends.

## Applicability

- Use this when foreground progress or a visible widget needs stop or detach controls.

## Structure

```text
active -> subscribe input
control -> consume / transform
end -> unsubscribe
```

## Participants

- [`ctx.ui.onTerminalInput`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#interact-with-the-user): Installs a raw-input handler and returns its unsubscriber.
- [`ctx.ui.setWidget`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#interact-with-the-user): Mounts, replaces or clears one near-editor widget.
- [`ExtensionContext.mode`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#account-for-each-mode): Separates TUI from RPC, JSON and print modes.
- `Unsubscriber`: Releases temporary interception when its owner ends.

Pi screen APIs: [`ctx.ui.onTerminalInput`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#interact-with-the-user), [`ctx.ui.setWidget`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#interact-with-the-user), [`ExtensionContext.mode`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#account-for-each-mode), `onTerminalInput`, `setWidget`, `matchesKey`

## Consequences

- Temporary stop or detach controls can coexist with the normal editor.
- Raw input interception needs TUI guards and bounded subscription lifetime.

## Implementation

- Subscribe only in TUI mode.
- Always unsubscribe on completion or cancellation.

## 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.

### Temporary controls

`active`: Show a synthetic running task and its cancel or detach hint.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Input Lease, Temporary controls, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/active-40-dark.1cd6d63e2ea4.webp) ![Input Lease, Temporary controls, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/active-40-light.591e78008ad2.webp)
- 60 columns: ![Input Lease, Temporary controls, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/active-60-dark.ef9455932073.webp) ![Input Lease, Temporary controls, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/active-60-light.c68e689e47cd.webp)
- 80 columns: ![Input Lease, Temporary controls, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/active-80-dark.a01417819628.webp) ![Input Lease, Temporary controls, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/active-80-light.85f7d666f1db.webp)
- 120 columns: ![Input Lease, Temporary controls, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/active-120-dark.a62bb5839eac.webp) ![Input Lease, Temporary controls, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/active-120-light.4e8391165d8c.webp)

### Control handled

`consumed`: Consume a task-specific control and update its visible state.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Input Lease, Control handled, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/consumed-40-dark.7dd7c923b876.webp) ![Input Lease, Control handled, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/consumed-40-light.5bcc7ace2754.webp)
- 60 columns: ![Input Lease, Control handled, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/consumed-60-dark.7773abc282b4.webp) ![Input Lease, Control handled, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/consumed-60-light.624f42bc250e.webp)
- 80 columns: ![Input Lease, Control handled, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/consumed-80-dark.3bf789ad37b5.webp) ![Input Lease, Control handled, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/consumed-80-light.05fd6086421a.webp)
- 120 columns: ![Input Lease, Control handled, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/consumed-120-dark.dc7ef5633057.webp) ![Input Lease, Control handled, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/consumed-120-light.354ea620c326.webp)

### Listener removed

`finished`: Return input to the normal editor after cleanup.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Input Lease, Listener removed, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/finished-40-dark.e32715385f61.webp) ![Input Lease, Listener removed, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/finished-40-light.5b11c87f32b2.webp)
- 60 columns: ![Input Lease, Listener removed, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/finished-60-dark.3f257d2b8e8d.webp) ![Input Lease, Listener removed, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/finished-60-light.eceac85f38d0.webp)
- 80 columns: ![Input Lease, Listener removed, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/finished-80-dark.9626439684f8.webp) ![Input Lease, Listener removed, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/finished-80-light.58cf1eb8fa54.webp)
- 120 columns: ![Input Lease, Listener removed, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/input-lease/finished-120-dark.faff04f5dfd2.webp) ![Input Lease, Listener removed, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/input-lease/finished-120-light.0984d37531eb.webp)

## Sample code

`stories/patterns/lifecycle/input-lease.ts`, the story the frames above were rendered from.

```ts
// Input Lease: release raw terminal interception when its operation ends.
import { matchesKey, Key } from "@earendil-works/pi-tui";
import type { PatternStory } from "../../../src/pattern.ts";

export const story: PatternStory = {
  id: "input-lease", title: "Input Lease", kind: "screen",
  apis: ["onTerminalInput", "setWidget", "matchesKey"],
  states: [
    { id: "active", label: "Temporary controls" },
    { id: "consumed", label: "Control handled", steps: [
      { type: "keys", data: "\x04" }, { type: "action", name: "assert-consumed" },
    ] },
    { id: "finished", label: "Listener removed", steps: [
      { type: "action", name: "finish" }, { type: "text", text: "Review detached output" },
      { type: "action", name: "assert-released" },
    ] },
  ],
  setup({ ui, mode, theme, action }) {
    ui.setEditorText("Draft: ");
    ui.setWidget("task", [theme.fg("accent", "Parser checks · running"),
      theme.fg("muted", "Ctrl+D detaches · editor stays usable")]);
    // Lifecycle: active -> detached -> finished; cleanup is idempotent.
    let unsubscribe: (() => void) | undefined;
    let interceptedInputs = 0, inputsAtRelease = 0;
    if (mode === "tui") unsubscribe = ui.onTerminalInput(data => {
      interceptedInputs++;
      if (!matchesKey(data, Key.ctrl("d"))) return;
      ui.setWidget("task", [theme.fg("success", "Detached · checks run in background"),
        theme.fg("muted", "Ctrl+D consumed; draft unchanged")]);
      return { consume: true };
    });
    action("assert-consumed", () => {
      if (interceptedInputs !== 1 || ui.getEditorText() !== "Draft: ") throw new Error("Control was not consumed");
    });
    const release = () => { unsubscribe?.(); unsubscribe = undefined; };
    action("finish", () => {
      release();
      inputsAtRelease = interceptedInputs;
      ui.setWidget("task", [theme.fg("success", "Checks done · input lease released")]);
    });
    action("assert-released", () => {
      if (interceptedInputs !== inputsAtRelease) throw new Error("Input listener survived cleanup");
      if (ui.getEditorText() !== "Draft: Review detached output") throw new Error("Editor did not reclaim input");
    });
    return release;
  },
};
```

## Known uses: seen in Nico's repos

- [**dot314**](https://github.com/nicobailon/dot314): Bound input interception to foreground progress
  - [extensions/handover/index.ts:700-747](https://github.com/nicobailon/dot314/blob/17cce138f3f687211d0485d204408a9650953c61/extensions/handover/index.ts#L700-L747) @17cce138
- [**pi-prompt-template-model**](https://github.com/nicobailon/pi-prompt-template-model): Bound input interception to foreground progress
  - [subagent-step.ts:443-462](https://github.com/nicobailon/pi-prompt-template-model/blob/6da205917e549cbe8e855c1d241c616ae1fb1627/subagent-step.ts#L443-L462) @6da20591
  - [subagent-step.ts:533-558](https://github.com/nicobailon/pi-prompt-template-model/blob/6da205917e549cbe8e855c1d241c616ae1fb1627/subagent-step.ts#L533-L558) @6da20591
  - [subagent-step.ts:748-762](https://github.com/nicobailon/pi-prompt-template-model/blob/6da205917e549cbe8e855c1d241c616ae1fb1627/subagent-step.ts#L748-L762) @6da20591
- [**pi-prune**](https://github.com/nicobailon/pi-prune): Bound input interception to foreground progress
  - [index.ts:63-75](https://github.com/nicobailon/pi-prune/blob/0194b7a3eb87db71648fef672effbfb65411a82a/index.ts#L63-L75) @0194b7a3
  - [index.ts:131-132](https://github.com/nicobailon/pi-prune/blob/0194b7a3eb87db71648fef672effbfb65411a82a/index.ts#L131-L132) @0194b7a3
- [**pi-subagents**](https://github.com/nicobailon/pi-subagents): Bound input interception to foreground progress
  - [src/slash/slash-commands.ts:350-440](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/slash/slash-commands.ts#L350-L440) @6826b054
  - [src/slash/slash-commands.ts:770-835](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/slash/slash-commands.ts#L770-L835) @6826b054
- [**pi-subagents**](https://github.com/nicobailon/pi-subagents): Install terminal input listeners with explicit teardown
  - [src/tui/fleet-status.ts:575-600](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/tui/fleet-status.ts#L575-L600) @6826b054
  - [src/tui/fleet-status.ts:1080-1130](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/tui/fleet-status.ts#L1080-L1130) @6826b054

## For agents: choose and check

Choose Input Lease 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.
- [Ghost Overlay](https://pi-tui.ratstack.sh/patterns/ghost-overlay.md): Keep an overlay visible without automatically taking keyboard focus.
- [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.

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.onTerminalInput`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#interact-with-the-user), [`ctx.ui.setWidget`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#interact-with-the-user), [`ExtensionContext.mode`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#account-for-each-mode), `onTerminalInput`, `setWidget`, `matchesKey` before you use them.
- Every reference frame gets the verdict its state expects.

Next actions: `related({ id: "input-lease" })` lists what to read next, and `states({ id: "input-lease", 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

- [Action Compass](https://pi-tui.ratstack.sh/patterns/action-compass.md): resolves configurable component actions

## Linked from

- [Action Compass](https://pi-tui.ratstack.sh/patterns/action-compass.md): intercepts raw input instead
