All posts

Requesting Display Mode Transitions in MCP Apps

Abe Wheeler
MCP Apps MCP App Framework ChatGPT Apps ChatGPT App Framework Claude Apps Reference
MCP App resources can request display mode transitions directly from user interactions.

MCP App resources can request display mode transitions directly from user interactions.

TL;DR: Use useDisplayMode() to read the current mode and useApp() to call requestDisplayMode. Tie requests to user interactions, not component mount. Test all three modes in the sunpeak inspector without a paid account.

The display mode reference explains what inline, fullscreen, and picture-in-picture look like from the outside. This post covers the other direction: how your resource asks the host to change modes.

The use cases are common. A data table that needs more space. A code diff the user wants to study. A dashboard that works better without the conversation column. All of these want a button that says “expand.”

The Two Hooks

Reading and writing display mode involve two different hooks.

useDisplayMode returns the current mode as 'inline' | 'pip' | 'fullscreen'. It updates reactively when the host changes the mode, so your layout can adapt without any extra logic.

useApp returns the connected App instance, which exposes requestDisplayMode. It returns null while the connection is being established, so the ?. optional chain is required.

import { useDisplayMode, useApp } from 'sunpeak';

export function MyResource() {
  const displayMode = useDisplayMode(); // 'inline' | 'pip' | 'fullscreen'
  const app = useApp();                 // App | null

  const goFullscreen = () => {
    app?.requestDisplayMode({ mode: 'fullscreen' });
  };

  return (
    <button onClick={goFullscreen}>Expand</button>
  );
}

The host decides whether to honor the request. In practice, hosts honor user-initiated transitions immediately.

Pattern 1: Expand to Fullscreen

The most common pattern: show an expand button in inline and PiP modes, hide it in fullscreen (where it has no effect).

import { useDisplayMode, useApp, useToolData, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';

export const resource: ResourceConfig = {
  description: 'Display a data table',
};

interface TableData {
  columns: string[];
  rows: Array<Record<string, string>>;
  title: string;
}

export function TableResource() {
  const displayMode = useDisplayMode();
  const app = useApp();
  const { output } = useToolData<unknown, TableData>(undefined, undefined);

  if (!output) return null;

  const isFullscreen = displayMode === 'fullscreen';

  return (
    <SafeArea
      style={{
        fontFamily: 'var(--font-sans)',
        background: 'var(--color-background-primary)',
        color: 'var(--color-text-primary)',
        padding: '1.25rem',
        height: '100%',
      }}
    >
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
        <h1 style={{ fontSize: 'var(--text-md)', fontWeight: 'var(--font-weight-semibold)', margin: 0 }}>
          {output.title}
        </h1>
        {!isFullscreen && (
          <button
            onClick={() => app?.requestDisplayMode({ mode: 'fullscreen' })}
            style={{
              fontSize: 'var(--text-xs)',
              color: 'var(--color-text-secondary)',
              background: 'var(--color-background-secondary)',
              border: '1px solid var(--color-border-primary)',
              borderRadius: 'var(--border-radius-sm)',
              padding: '0.25rem 0.5rem',
              cursor: 'pointer',
            }}
          >
            Expand
          </button>
        )}
      </div>

      <div style={{ overflowX: 'auto' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--text-sm)' }}>
          <thead>
            <tr>
              {output.columns.map((col) => (
                <th
                  key={col}
                  style={{
                    textAlign: 'left',
                    padding: '0.5rem 0.75rem',
                    borderBottom: '1px solid var(--color-border-primary)',
                    fontWeight: 'var(--font-weight-medium)',
                    color: 'var(--color-text-secondary)',
                  }}
                >
                  {col}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {output.rows.map((row, i) => (
              <tr key={i}>
                {output.columns.map((col) => (
                  <td
                    key={col}
                    style={{
                      padding: '0.5rem 0.75rem',
                      borderBottom: '1px solid var(--color-border-primary)',
                    }}
                  >
                    {row[col]}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </SafeArea>
  );
}

The !isFullscreen check means the button disappears once the user is in fullscreen. The host already provides an X button to close, so you don’t need your own.

Pattern 2: Pop Out to PiP

PiP mode is useful for references the user wants to keep visible while continuing the conversation. A glossary, a running score, a status panel.

const handlePopOut = () => {
  app?.requestDisplayMode({ mode: 'pip' });
};

One thing to know: the MCP Apps protocol allows PiP on any device, but some hosts (ChatGPT in particular) promote PiP to fullscreen on mobile screen widths. You do not need to handle this fallback yourself since the host manages it. If you want to know whether the user is on mobile, read useHostContext().platform:

import { useHostContext } from 'sunpeak';

const ctx = useHostContext();
const isMobile = ctx?.platform === 'mobile';

// PiP becomes fullscreen on mobile anyway, but you can skip the PiP button on mobile
// if you'd rather show "expand" directly

Pattern 3: Back to Inline

Some resources open in fullscreen or PiP by default and include a “Done” or “Close” button that returns to inline mode. This is less common because hosts provide their own close controls, but it works when you want a specific call-to-action at the end of a flow.

const handleClose = () => {
  app?.requestDisplayMode({ mode: 'inline' });
};

A form that switches to fullscreen for editing and back to inline after submit is a natural fit for this pattern:

import { useDisplayMode, useApp, useToolData, useAppState, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';

export const resource: ResourceConfig = {
  description: 'Edit a document',
};

interface DocumentData {
  id: string;
  title: string;
  body: string;
}

interface EditorState {
  saved: boolean;
}

export function EditorResource() {
  const displayMode = useDisplayMode();
  const app = useApp();
  const { output } = useToolData<unknown, DocumentData>(undefined, undefined);
  const [state, setState] = useAppState<EditorState>({ saved: false });

  if (!output) return null;

  if (state.saved) {
    return (
      <SafeArea style={{ padding: '1.25rem', fontFamily: 'var(--font-sans)', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
        Saved.
      </SafeArea>
    );
  }

  const handleOpen = () => app?.requestDisplayMode({ mode: 'fullscreen' });
  const handleSave = () => {
    setState({ saved: true });
    app?.requestDisplayMode({ mode: 'inline' });
  };

  return (
    <SafeArea
      style={{
        fontFamily: 'var(--font-sans)',
        background: 'var(--color-background-primary)',
        padding: '1.25rem',
        height: '100%',
        display: 'flex',
        flexDirection: 'column',
        gap: '0.75rem',
      }}
    >
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
        <h1 style={{ fontSize: 'var(--text-md)', fontWeight: 'var(--font-weight-semibold)', margin: 0 }}>
          {output.title}
        </h1>
        {displayMode === 'inline' && (
          <button
            onClick={handleOpen}
            style={{
              fontSize: 'var(--text-xs)',
              color: 'var(--color-text-secondary)',
              background: 'var(--color-background-secondary)',
              border: '1px solid var(--color-border-primary)',
              borderRadius: 'var(--border-radius-sm)',
              padding: '0.25rem 0.5rem',
              cursor: 'pointer',
            }}
          >
            Edit
          </button>
        )}
      </div>

      {displayMode === 'fullscreen' && (
        <>
          <textarea
            defaultValue={output.body}
            style={{
              flex: 1,
              fontFamily: 'var(--font-mono)',
              fontSize: 'var(--text-sm)',
              background: 'var(--color-background-secondary)',
              border: '1px solid var(--color-border-primary)',
              borderRadius: 'var(--border-radius-md)',
              padding: '0.75rem',
              color: 'var(--color-text-primary)',
              resize: 'none',
            }}
          />
          <button
            onClick={handleSave}
            style={{
              alignSelf: 'flex-end',
              padding: '0.5rem 1rem',
              background: 'var(--color-background-info)',
              color: 'var(--color-text-info)',
              border: '1px solid var(--color-border-info)',
              borderRadius: 'var(--border-radius-md)',
              fontSize: 'var(--text-sm)',
              fontWeight: 'var(--font-weight-medium)',
              cursor: 'pointer',
            }}
          >
            Save
          </button>
        </>
      )}
    </SafeArea>
  );
}

This resource shows a title with an “Edit” button in inline mode. Clicking “Edit” opens fullscreen. Clicking “Save” marks the document saved and returns to inline. The model sees { saved: true } via useAppState and can continue the conversation from there.

Combining useDisplayMode and useApp

useDisplayMode and useApp compose naturally. Read the current mode to decide what to show, write with useApp to request what the user wants:

import { useDisplayMode, useApp } from 'sunpeak';

function ModeControls() {
  const displayMode = useDisplayMode();
  const app = useApp();

  if (displayMode === 'fullscreen') return null; // host provides close button

  return (
    <div style={{ display: 'flex', gap: '0.5rem' }}>
      {displayMode === 'inline' && (
        <>
          <button onClick={() => app?.requestDisplayMode({ mode: 'pip' })}>
            Pop out
          </button>
          <button onClick={() => app?.requestDisplayMode({ mode: 'fullscreen' })}>
            Expand
          </button>
        </>
      )}
      {displayMode === 'pip' && (
        <button onClick={() => app?.requestDisplayMode({ mode: 'fullscreen' })}>
          Fullscreen
        </button>
      )}
    </div>
  );
}

This renders two buttons from inline, one from PiP, and nothing from fullscreen. Each maps to a mode that provides more space than the current one.

Testing Display Mode Transitions

The sunpeak inspector sidebar has a display mode dropdown. Toggle it to verify your layout adapts correctly in each mode. For automated tests, pass displayMode to createInspectorUrl:

import { test, expect } from '@playwright/test';
import { createInspectorUrl } from 'sunpeak/chatgpt';

const displayModes = ['inline', 'pip', 'fullscreen'] as const;

for (const displayMode of displayModes) {
  test(`table renders expand button in ${displayMode}`, async ({ page }) => {
    await page.goto(
      createInspectorUrl({
        simulation: 'show-table',
        host: 'chatgpt',
        displayMode,
      })
    );
    const iframe = page.frameLocator('iframe');

    if (displayMode === 'fullscreen') {
      // No expand button in fullscreen
      await expect(iframe.locator('text=Expand')).not.toBeVisible();
    } else {
      await expect(iframe.locator('text=Expand')).toBeVisible();
    }
  });
}

To test the transition itself, click the button inside the iframe. The inspector processes requestDisplayMode calls the same way a real host does:

test('clicking expand requests fullscreen', async ({ page }) => {
  await page.goto(
    createInspectorUrl({
      simulation: 'show-table',
      host: 'chatgpt',
      displayMode: 'inline',
    })
  );

  const iframe = page.frameLocator('iframe');
  await iframe.locator('text=Expand').click();

  // After the request, the inspector switches to fullscreen
  // and the expand button disappears
  await expect(iframe.locator('text=Expand')).not.toBeVisible();
});

No paid ChatGPT or Claude accounts required. The inspector handles mode transitions locally, so these tests run in CI.

A Few Practical Notes

The host is not required to honor every requestDisplayMode call. If your resource is in a context where fullscreen is unavailable (say, an embedded integration), the request may be ignored. useDisplayMode will stay at the current value.

Tying requests to user interactions is more reliable than calling requestDisplayMode on mount. Hosts may deprioritize or ignore requests that are not triggered by a user gesture, and auto-expanding without user intent is generally bad UX.

The request is asynchronous in the sense that requestDisplayMode does not block. The mode change comes back as a useDisplayMode update when the host processes it. You do not need to manage a pending state yourself.

Where to Go Next

Build MCP Apps that use the full screen when they need it with sunpeak. pnpm add -g sunpeak && sunpeak new to get started.

Get Started

Documentation →
pnpm add -g sunpeak && sunpeak new

Frequently Asked Questions

How do I make my MCP App go fullscreen?

Use the useApp hook from sunpeak to get the App instance, then call app.requestDisplayMode({ mode: "fullscreen" }). The host honors the request if the user is in a context where fullscreen is available. Pair it with useDisplayMode to know when the transition completes so you can adapt your layout.

What is the useApp hook in sunpeak?

useApp is a sunpeak React hook that returns the connected App instance (or null while connecting). It provides direct access to SDK methods that have no dedicated hook wrapper, primarily requestDisplayMode. Import it from sunpeak alongside useToolData and useDisplayMode.

What display modes can an MCP App request?

MCP App resources can request three display modes: "inline" (embedded in the conversation), "pip" (picture-in-picture floating window), and "fullscreen" (takes over the conversation area). The host decides whether to honor the request. ChatGPT promotes PiP requests to fullscreen on mobile screen widths, though this is a host implementation choice, not a protocol requirement.

What is the difference between useDisplayMode and useApp in MCP Apps?

useDisplayMode reads the current display mode (inline, pip, or fullscreen). useApp gives you the App instance so you can call requestDisplayMode to request a new one. Use both together: read with useDisplayMode to know what to show, write with useApp to request changes when the user interacts.

How do I add an expand button to my MCP App resource?

Read the current mode with useDisplayMode and the App instance with useApp. When the user clicks your expand button, call app?.requestDisplayMode({ mode: "fullscreen" }). Show or hide the button based on the current mode — hide it when already in fullscreen so it only appears when it has an effect.

Does MCP App PiP mode work on mobile?

It depends on the host. The MCP Apps protocol allows PiP on any device, but ChatGPT promotes PiP to fullscreen on mobile screen widths. If you call requestDisplayMode({ mode: "pip" }) on mobile in ChatGPT, the host shows fullscreen instead. Other hosts may handle this differently. You do not need to handle this fallback yourself since the host manages it.

How do I test display mode transitions in the sunpeak inspector?

Use the display mode dropdown in the sunpeak dev sidebar to toggle between inline, pip, and fullscreen and verify your layout adapts correctly. For automated tests, pass the displayMode parameter to createInspectorUrl in your Playwright test. Pass multiple display modes in a loop to cover all transitions without a paid ChatGPT or Claude account.

Can I request a display mode transition on component mount?

Technically yes, but it is rarely a good UX. Hosts may ignore programmatic requests that are not triggered by a user gesture. The reliable pattern is tying requestDisplayMode to a button click or other explicit user action. Auto-expanding on load can also feel surprising to users who just started a conversation.