# Shared Shell

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

Structural / Overlays and dialogs · `shared-shell` · [HTML](https://pi-tui.ratstack.sh/patterns/shared-shell/) · [JSON](https://pi-tui.ratstack.sh/patterns/shared-shell.json) · [all patterns](https://pi-tui.ratstack.sh/patterns.md)

Also known as Modal frame.

## Intent

Wrap specialized dialog content in a shared themed frame.

## Motivation

Tool Display's Zellij modal reuses framing around specialized settings content.

## Applicability

- Use this when several custom dialogs need the same title, border and controls.

## Structure

```text
frame(title, theme)
  + content(render, input, dispose)
  -> custom 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.
- [`Component`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model): Renders width-bounded lines and invalidates cached output.
- [`truncateToWidth`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model): Clips text to its allotted columns and can pad the result.
- `Content contract`: Supplies specialized render, input and disposal behavior.

Pi component 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), [`Component`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), [`truncateToWidth`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), `Text`, `visibleWidth`

## Consequences

- Different dialogs share borders, titles and controls.
- The frame still needs a small content and disposal contract.

## Implementation

- Keep content state outside the shared frame.
- Clip titles before inserting them between borders.
- The source snapshot does not declare Pi 1.0.3 support.

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

### First content

`first`: Show a framed synthetic settings view.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Shared Shell, First content, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/first-40-dark.6b888fd4038a.webp) ![Shared Shell, First content, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/first-40-light.84ac309dcd84.webp)
- 60 columns: ![Shared Shell, First content, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/first-60-dark.ff643e72e74e.webp) ![Shared Shell, First content, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/first-60-light.e84990a34ac6.webp)
- 80 columns: ![Shared Shell, First content, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/first-80-dark.1574f0e124dc.webp) ![Shared Shell, First content, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/first-80-light.7310fc2da497.webp)
- 120 columns: ![Shared Shell, First content, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/first-120-dark.2aa966a12500.webp) ![Shared Shell, First content, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/first-120-light.920e12ddd3a7.webp)

### Different content

`second`: Reuse the frame around an inspection view.

Checks at every width and theme:

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

Frames:

- 40 columns: ![Shared Shell, Different content, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/second-40-dark.253aa46dd737.webp) ![Shared Shell, Different content, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/second-40-light.9d1c0706095c.webp)
- 60 columns: ![Shared Shell, Different content, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/second-60-dark.1432a3a84617.webp) ![Shared Shell, Different content, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/second-60-light.4f006fc58f7e.webp)
- 80 columns: ![Shared Shell, Different content, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/second-80-dark.d29ecf0239e5.webp) ![Shared Shell, Different content, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/second-80-light.7731f705d360.webp)
- 120 columns: ![Shared Shell, Different content, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/second-120-dark.3796f0b0cd8c.webp) ![Shared Shell, Different content, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/second-120-light.6bf6aafd6ce8.webp)

### Don't: unbounded title

`dont-long-title`: Place an untruncated long title between borders so it exceeds width.

This state is a counter-example. It fails width on purpose.

Checks at every width and theme:

- width: ✓ fails, as intended
- style-leak: ✓ pass
- hard-coded-colour: ✓ pass
- height: ✓ pass

Evidence from the checker:

- width at 40 dark: line 1, column 41: frame 0: visibleWidth=181, limit=40
- width at 40 dark: line 2, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 40 dark: line 3, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 40 dark: line 4, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 40 dark: line 5, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 40 light: line 1, column 41: frame 0: visibleWidth=181, limit=40
- width at 40 light: line 2, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 40 light: line 3, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 40 light: line 4, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 40 light: line 5, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 60 dark: line 1, column 61: frame 0: visibleWidth=181, limit=60
- width at 60 dark: line 2, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 60 dark: line 3, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 60 dark: line 4, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 60 light: line 1, column 61: frame 0: visibleWidth=181, limit=60
- width at 60 light: line 2, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 60 light: line 3, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 60 light: line 4, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 80 dark: line 1, column 81: frame 0: visibleWidth=181, limit=80
- width at 80 dark: line 2, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 80 dark: line 3, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 80 light: line 1, column 81: frame 0: visibleWidth=181, limit=80
- width at 80 light: line 2, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 80 light: line 3, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 120 dark: line 1, column 121: frame 0: visibleWidth=181, limit=120
- width at 120 dark: line 2, column 1: frame 0: headless terminal row is wrapped (physical row)
- width at 120 light: line 1, column 121: frame 0: visibleWidth=181, limit=120
- width at 120 light: line 2, column 1: frame 0: headless terminal row is wrapped (physical row)

Frames:

- 40 columns: ![Shared Shell, Don't: unbounded title, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/dont-long-title-40-dark.6b70dcb7e54e.webp) ![Shared Shell, Don't: unbounded title, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/dont-long-title-40-light.ed91578e9a42.webp)
- 60 columns: ![Shared Shell, Don't: unbounded title, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/dont-long-title-60-dark.d19ec61a3bd8.webp) ![Shared Shell, Don't: unbounded title, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/dont-long-title-60-light.f987b68b291f.webp)
- 80 columns: ![Shared Shell, Don't: unbounded title, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/dont-long-title-80-dark.c3066d4b9789.webp) ![Shared Shell, Don't: unbounded title, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/dont-long-title-80-light.4b7158b2655a.webp)
- 120 columns: ![Shared Shell, Don't: unbounded title, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/shared-shell/dont-long-title-120-dark.b98e6e9dbffb.webp) ![Shared Shell, Don't: unbounded title, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/shared-shell/dont-long-title-120-light.1bdacc3fd1ee.webp)

## Sample code

`stories/patterns/structural/shared-shell.ts`, the story the frames above were rendered from.

```ts
// Shared Shell: keep specialized content outside a reusable themed frame.
import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
import type { PatternStory } from "../../../src/pattern.ts";

type ShellContent = { title: string; view: Component & { dispose?(): void } };

export const story: PatternStory = {
  id: "shared-shell", title: "Shared Shell", kind: "component",
  apis: ["Component", "Text", "truncateToWidth", "visibleWidth"],
  states: [
    { id: "first", label: "First content" },
    { id: "second", label: "Different content", steps: [{ type: "action", name: "inspect" }] },
    { id: "dont-long-title", label: "Don't: unbounded title",
      steps: [{ type: "action", name: "long-title" }], expectFail: ["width"] },
  ],
  setup({ theme, action, tui }) {
    const settings: ShellContent = { title: "Preview settings",
      view: new Text(theme.fg("text", "Theme: follows Pi\nWidth: terminal columns\nReview: both themes"), 0, 0) };
    const inspection: ShellContent = { title: "Inspect preview",
      view: new Text(theme.fg("text", "File: src/preview.ts\nResult: 8 frames ready\nNext: inspect narrow layout"), 0, 0) };
    let content = settings;
    let clipTitle = true;
    const replace = (next: ShellContent) => {
      content.view.dispose?.(); content = next; tui.requestRender();
    };
    action("inspect", () => replace(inspection));
    action("long-title", () => {
      content = { ...inspection, title: "Inspect preview for the synthetic task workspace: render the light and dark task-list frames, compare the narrow and wide layouts, then review the sample code before publishing" };
      clipTitle = false; tui.requestRender();
    });
    const shell = {
      invalidate() { content.view.invalidate(); },
      handleInput(data: string) { content.view.handleInput?.(data); tui.requestRender(); },
      dispose() { content.view.dispose?.(); },
      render(width: number) {
        const inner = width - 4;
        const title = clipTitle ? truncateToWidth(content.title, width - 6, "…") : content.title;
        const heading = "┌─ " + title + " " + "─".repeat(Math.max(0, width - visibleWidth(title) - 5)) + "┐";
        return [
          theme.fg("borderAccent", heading),
          ...content.view.render(inner).map(line => theme.fg("border", "│ ") +
            truncateToWidth(line, inner, "…", true) + theme.fg("border", " │")),
          theme.fg("border", "│ ") + theme.fg("muted", truncateToWidth("Read-only view · shared frame", inner, "…", true)) +
            theme.fg("border", " │"),
          theme.fg("borderAccent", "└" + "─".repeat(width - 2) + "┘"),
        ];
      },
    } satisfies Component & { dispose(): void };
    return shell;
  },
};
```

## Known uses: seen in Nico's repos

- [**pi-tool-display**](https://github.com/nicobailon/pi-tool-display): Zellij modal: reusable frame and content contract
  - [src/zellij-modal.ts:240-275](https://github.com/nicobailon/pi-tool-display/blob/fca8c858a0989b63eba18ab935f3d8ed78354c3a/src/zellij-modal.ts#L240-L275) @fca8c858
  - [src/zellij-modal.ts:720-755](https://github.com/nicobailon/pi-tool-display/blob/fca8c858a0989b63eba18ab935f3d8ed78354c3a/src/zellij-modal.ts#L720-L755) @fca8c858

## For agents: choose and check

Choose Shared Shell when your job matches its intent and applicability above. Its neighbours in Overlays and dialogs are listed below. Read the one whose intent fits your job more closely before you commit.

- [Warning Gate](https://pi-tui.ratstack.sh/patterns/warning-gate.md): Follow a consequential selection with a separate warning confirmation.
- [Abort Lantern](https://pi-tui.ratstack.sh/patterns/abort-lantern.md): Settle foreground loading before opening a separate result view.
- [Dialog Fuse](https://pi-tui.ratstack.sh/patterns/dialog-fuse.md): Show remaining time before a transient dialog automatically dismisses.
- [Abort Tether](https://pi-tui.ratstack.sh/patterns/abort-tether.md): Tie a built-in dialog's lifetime to an operation's abort signal.
- [Idle Fuse](https://pi-tui.ratstack.sh/patterns/idle-fuse.md): Reset a component-owned inactivity timer after every input.
- [Settings Bench](https://pi-tui.ratstack.sh/patterns/settings-bench.md): Keep configuration interaction separate from the live activity view.
- [Action Sieve](https://pi-tui.ratstack.sh/patterns/action-sieve.md): Derive dialog choices from current control and completion state.
- [Done Contract](https://pi-tui.ratstack.sh/patterns/done-contract.md): Resolve each custom interaction with a typed selection or cancellation.

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.
- "Don't: unbounded title" is a counter-example. Its frames fail width on purpose. Your version should not look like it.
- 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), [`Component`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), [`truncateToWidth`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), `Text`, `visibleWidth` before you use them.
- Every reference frame gets the verdict its state expects.

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

- [Settings Bench](https://pi-tui.ratstack.sh/patterns/settings-bench.md): supplies specialized settings content

## Linked from

No other pattern links here.
