## Editors\,&#32;themes and composer shapes

Luis likes Review Desk’s separation between data and presentation\.&#32;His next goal is a more comfortable authoring environment\:&#32;a phrase shortcut\,&#32;a theme picker and a quieter composer frame\.

He chooses independent UI conveniences rather than changing the review tool’s meaning\.

The examples in this chapter are&#32;**new complete exercises**\,&#32;not additional files in the observed downloadable suite\.

### Milestone\:&#32;insert text without submitting it

**Complete TypeScript exercise—save as&#32;`phrase-key.ts`\:**

~~~ts
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";

export default function phraseKey(pi: ExtensionAPI): void {
    pi.registerShortcut("ctrl+shift+y", {
        description: "Paste a fictional-observation prefix",
        handler(ctx) {
            if (ctx.mode !== "tui") return;
            ctx.ui.pasteToEditor("Fictional observation: ");
        },
    });
}
~~~

**Terminal shell—from the directory containing that exercise\:**

~~~sh
omp --no-extensions -e ./phrase-key.ts
~~~

**Expected checkpoint\:**&#32;the key chord pastes text through the editor’s paste handling\.&#32;It does not submit a prompt or call a provider\.

The custom host already uses&#32;`ctrl+shift+x`&#32;for its built\-in autoresearch overlay\.&#32;This exercise uses&#32;`ctrl+shift+y`&#32;instead\:&#32;a valid chord can still lose to a later registration\.&#32;Verify the final handler in your host\,&#32;not merely that your factory registered it\.

`setEditorText()`&#32;replaces the editor text\.&#32;`pasteToEditor()`&#32;follows the TUI paste path\,&#32;including large\-paste handling\.&#32;`getEditorText()`&#32;reads the current TUI text\.

RPC’s&#32;`pasteToEditor()`&#32;falls back to a&#32;`set_editor_text`&#32;request\.&#32;Its synchronous&#32;`getEditorText()`&#32;returns an empty string\;&#32;the remote host must track its own composer state\.

**Exercise\:**&#32;should a timer call&#32;`pasteToEditor()`&#32;to force an agent turn\?

**Answer\:**&#32;no\.&#32;Editor mutation is not prompt submission\.&#32;Use an explicit message API if an agent turn is intended\,&#32;with the appropriate ownership and cost policy\.

### Milestone\:&#32;select a theme that actually exists

Luis does not hard\-code a theme name or invent a theme\-file schema\.&#32;He asks the host for its available themes\.

**Complete TypeScript exercise—save as&#32;`theme-picker.ts`\:**

~~~ts
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";

export default function themePicker(pi: ExtensionAPI): void {
    pi.registerCommand("workbook-theme", {
        description: "Choose an available TUI theme",
        async handler(_args, ctx) {
            if (ctx.mode !== "tui") {
                pi.sendMessage(
                    { customType: "workbook.theme", content: "Theme selection requires the TUI.", display: true },
                    { triggerTurn: false },
                );
                return;
            }
            const themes = await ctx.ui.getAllThemes();
            const choice = await ctx.ui.select("Workbook theme", themes.map(theme => theme.name));
            if (choice === undefined) return;
            const result = await ctx.ui.setTheme(choice);
            ctx.ui.notify(
                result.success ? `Theme selected: ${choice}` : result.error ?? "Theme selection failed.",
                result.success ? "info" : "warning",
            );
        },
    });
}
~~~

**Human OMP slash command\,&#32;after loading the file\:**

~~~text
/workbook-theme
~~~

`getTheme(name)`&#32;loads a theme without selecting it\.&#32;`ui.theme`&#32;is the current styling object\.

Although the type permits&#32;`setTheme(Theme)`\,&#32;the supplied TUI implementation accepts names and returns failure for a direct theme object\.&#32;RPC and ACP return theme\-switching failures and no available theme list\.

For authoring a new declarative theme file\,&#32;start from the matching host’s actual theme schema and a real theme file\.&#32;That schema is not included in the supplied extension evidence\,&#32;so this workbook does not invent theme JSON keys\.

**Exercise\:**&#32;cancel the picker\.

**Checkpoint\:**&#32;no theme change\.&#32;A present method or a successful headless no\-op would not prove theme switching\.

### Milestone\:&#32;register a composer shape

A composer shape owns the editor’s frame\,&#32;not its domain state\.

**Complete TypeScript exercise—save as&#32;`workbook-dock.ts`\:**

~~~ts
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
import type { ComposerStyle } from "@oh-my-pi/pi-tui";

const style: ComposerStyle = {
    id: "workbook-field-dock",
    sideBorders: false,
    verticalChrome: 1,
    statusAttachment: "none",
    bottomBar: "full",
    bottomBarGap: true,
    defaultPromptGutter: "> ",
    defaultPaddingX: () => 0,
    sideChromeWidth: () => 0,
    renderTop: ({ box, width, borderColor }) =>
        borderColor(box.horizontal.repeat(width)),
    renderRow: ({ gutter, text, pad }) => [gutter + text + pad],
    renderBottom: () => undefined,
};

export default function workbookDock(pi: ExtensionAPI): void {
    pi.registerComposerShape({
        label: "Workbook Field Dock",
        description: "A single rule above the prompt",
        style,
    });
}
~~~

Load it explicitly\,&#32;then choose&#32;**Workbook Field Dock**&#32;in Appearance → Composer Shape\.

Registration adds a choice\;&#32;it does not automatically select it\.

The shape contract includes\:

- `id`\,&#32;persisted as&#32;`composer.shape`\;
- `sideBorders`\,&#32;which affects cursor reserve\,&#32;IME behavior and scrollbar layout\;
- `verticalChrome`\,&#32;the exact fixed chrome\-row count\;
- `statusAttachment`\:&#32;`top-border`\,&#32;`top-rule-chip`&#32;or&#32;`none`\;
- `bottomBar`\:&#32;`none`\,&#32;`left`&#32;or&#32;`full`\;
- `bottomBarGap`\;
- `defaultPromptGutter`\;
- `defaultPaddingX()`&#32;and&#32;`sideChromeWidth()`\;
- `renderTop()`\,&#32;`renderRow()`&#32;and&#32;`renderBottom()`\.

Renderer contexts supply width\,&#32;padding\,&#32;box glyphs and border\/accent\/surface styling functions\.&#32;Row contexts also supply the already\-rendered&#32;`gutter`\,&#32;`text`\,&#32;`pad`\,&#32;last\-row information\,&#32;cursor overflow\,&#32;IME\-safe\-tail state and scrollbar\-thumb state\.

Preserve those prepared content pieces\.&#32;Do not reflow or arbitrarily truncate them inside frame code\.&#32;Normal rows must occupy the expected visible width\;&#32;ANSI bytes are not visible columns\.

Built\-in IDs cannot be replaced\:

`box`\,&#32;`claude`\,&#32;`pi`\,&#32;`borderless`\,&#32;`rule`\,&#32;`field`\,&#32;`rail`\.

If the configured extension shape is unavailable\,&#32;the editor falls back to&#32;`box`\.

**Exercise\:**&#32;test the shape at a narrow width and with a long line ending at the cursor\.

**Checkpoint\:**&#32;no unexpected wrapping caused by frame\-width miscalculation\.&#32;A pure&#32;`renderRow()`&#32;unit test alone does not establish live editor\/IME behavior\.

### Milestone\:&#32;replace the editor only when a shortcut is insufficient

A custom editor must be a&#32;**`CustomEditor`&#32;subclass**\,&#32;not merely a plain TUI&#32;`Editor`&#32;or an arbitrary component\.&#32;The host expects application action keys and callbacks\.

This source\-checkout exercise uses the supplied class’s exact repository location\.&#32;Save it at the matching repository root and use a host built from that same checkout\;&#32;do not mix a copied source class with an unrelated binary\.

**Complete TypeScript source\-checkout exercise—save as&#32;`workbook-editor.ts`\:**

~~~ts
import type { ExtensionAPI } from "./packages/coding-agent/src/extensibility/extensions/types";
import { CustomEditor } from "./packages/coding-agent/src/modes/components/custom-editor";
import { matchesKey } from "@oh-my-pi/pi-tui";

class FictionalEditor extends CustomEditor {
    override handleInput(data: string): void {
        if (matchesKey(data, "ctrl+shift+y")) {
            this.insertText("[fictional] ");
            this.tui?.requestRender();
            return;
        }
        super.handleInput(data);
    }
}

export default function workbookEditor(pi: ExtensionAPI): void {
    pi.registerCommand("workbook-editor", {
        description: "Use the fictional-prefix editor: on | off",
        async handler(args, ctx) {
            if (ctx.mode !== "tui") {
                ctx.ui.notify("Custom editors require the TUI.", "warning");
                return;
            }
            if (args.trim() === "on") {
                ctx.ui.setEditorComponent(
                    (tui, theme, keybindings) => new FictionalEditor(tui, theme, keybindings),
                );
            } else if (args.trim() === "off") {
                ctx.ui.setEditorComponent(undefined);
            } else {
                ctx.ui.notify("Use /workbook-editor on or /workbook-editor off.", "warning");
            }
        },
    });
}
~~~

**Terminal shell—from the matching source repository root\:**

~~~sh
omp --no-extensions -e ./workbook-editor.ts
~~~

Do not load this and the phrase\-key exercise together\:&#32;they intentionally demonstrate two ways to own the same chord\.

**Expected checkpoint\:**&#32;ordinary typing and application shortcuts still pass to&#32;`super.handleInput()`\.&#32;Turning the editor off restores the default editor\.

Custom input behavior needs real composer tests\,&#32;including paste\,&#32;Escape\,&#32;autocomplete and submission\.&#32;Avoid adding a global raw\-input listener merely to implement an editor\-local key\.

### Autocomplete\,&#32;terminal listeners and renderers

`addAutocompleteProvider(factory)`&#32;wraps the current provider\.&#32;A good wrapper\:

- handles only its own prefix\;
- delegates other suggestions and completion application\;
- preserves built\-in slash\/file completion\;
- tolerates being reapplied when command metadata refreshes\.

The supplied controller stacks factories in registration order\.&#32;Contract tests cover healthy wrappers surviving a broken sibling factory\.&#32;Headless adapters accept and ignore these factories\.

`onTerminalInput(handler)`&#32;is lower\-level\.&#32;A handler may return&#32;`{ consume: true }`&#32;or replacement&#32;`data`\;&#32;it returns an unsubscribe function\.&#32;Use it only in TUI mode\,&#32;clean it up\,&#32;and do not confuse terminal input with desktop\-wide keystroke control\.

`getToolsExpanded()`&#32;and&#32;`setToolsExpanded()`&#32;control TUI tool\-output expansion\.&#32;RPC\/ACP return false and ignore the setter\.

A supplemental thinking renderer can add presentation after already\-visible thinking text\.

**Complete TypeScript exercise—save as&#32;`thinking-length.ts`\:**

~~~ts
import type { ExtensionFactory } from "@oh-my-pi/pi-coding-agent";
import { Container, Text } from "@oh-my-pi/pi-tui";

const extension: ExtensionFactory = pi => {
    pi.registerAssistantThinkingRenderer((context, theme) => {
        const container = new Container();
        container.addChild(new Text(theme.fg("dim", `thinking chars: ${context.text.length}`), 0, 0));
        return container;
    });
};

export default extension;
~~~

Its context includes&#32;`contentIndex`\,&#32;`thinkingIndex`\,&#32;visible&#32;`text`&#32;and&#32;`requestRender()`\.&#32;It must not mutate the message\.&#32;It does not reveal otherwise unavailable reasoning\,&#32;and it does not change provider context\.

Custom message renderers\,&#32;thinking renderers and tool renderers are different extension points\.&#32;Review Desk uses the first\;&#32;Seed Desk stage 3 uses a tool\-result renderer\.

*Source\,&#32;snapshot 2026\-08\-29\:&#32;`packages/tui/src/components/composer/types.ts`\,&#32;`ComposerStyle`\;&#32;`packages/coding-agent/src/modes/components/custom-editor.ts`\,&#32;`CustomEditor`\;&#32;`packages/coding-agent/src/modes/controllers/extension-ui-controller.ts`\;&#32;`packages/coding-agent/test/issue-4919-extension-autocomplete-provider.test.ts`\;&#32;`packages/coding-agent/examples/extensions/thinking-note.ts`\.*
