Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/adrs/038.client.ime-composition-at-cursor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ADR 038: client — Composition (IME / dictation) text at the cursor

**SPEC:** [client](../specs/client.md)
**Status:** Accepted
**Date:** 2026-10-09

---

## Context

macOS dictation (and IME input such as Pinyin or Japanese kana) shows its in-progress text as a *composition*: the browser fires `compositionstart` / `compositionupdate` / `compositionend` and renders the uncommitted text in whichever editable element has focus. In webtty, that text appears on a new line under the terminal, in the browser's default text color. The extra line pushes the canvas up until the composition ends.

The cause is in how ghostty-web (`Terminal.open`) sets up input:

- The `#terminal` container gets `contenteditable="true"` and `tabindex="0"`. ghostty-web also adds a hidden `<textarea>` (1×1px, `opacity: 0`, `clip-path: inset(50%)`, pinned at `left: 0; top: 0`) for keyboard and IME input.
- Only a `mousedown` on the **canvas** moves focus to the textarea. A click in the padding (ADR 033/034) or the edge extension (ADR 035) focuses the **container**. `term.focus()` and the browser's initial tab focus also go to the container.
- When the container has focus, the browser inserts the composition as a text node after the `<canvas>`. ghostty-web removes that node only at `compositionend` (`cleanupCompositionTextNodes`), so it is visible while dictation runs. It uses the body's default color and font and wraps onto its own line under the canvas.
- When the textarea has focus, the composition is invisible because the textarea is clipped. The macOS dictation and IME candidate panels then anchor to the textarea's position at the top-left corner instead of the cursor.

The committed text already reaches the PTY through `compositionend` → `onData` in either case, so input is not lost. Only the preview is in the wrong place and has the wrong style.

## Decision

The client takes over how composition is presented. The data path that ghostty-web's `InputHandler` sends to the PTY stays the same.

1. **One input target.** All text input goes to ghostty-web's textarea. A `focusin` listener on the container sends focus back to the textarea whenever the container itself gets focus. This covers clicks in the padding or edges, tab focus and `term.focus()`. A window `focus` listener does the same when the window comes back with the container still focused, because `focus()` in a background window moves `activeElement` without firing `focusin`. Because the container never holds focus, the browser never inserts composition text nodes into it. `contenteditable` stays on the container because ghostty-web's own handlers expect it.
2. **Anchor the textarea at the cursor.** On every animation frame, move the textarea to the pixel position of the terminal cursor cell, writing only the styles that changed. ghostty-web's `onRender` never fires and `onCursorMove` only reports row changes, so neither can drive this. The position is `term.buffer.active.cursorX` / `cursorY`, shifted down by `viewportY` and clamped to the grid, multiplied by the cell size from `renderer.getMetrics()`, and offset by the canvas position inside the container. The textarea stays anchored even before a composition starts, because macOS places the dictation microphone when dictation begins. The textarea gets the cell's line height. macOS then places the dictation microphone and the IME candidate window at the cursor.
3. **Show a themed preview at the cursor.** Add a small overlay element (`src/client/compose.ts`) with absolute position and `pointer-events: none`, placed over the cursor cell. It is updated from `compositionupdate` (`event.data`) and hidden on `compositionend` or `blur`. It uses the terminal's `fontFamily` and `fontSize`, `theme.foreground` on `theme.background`, and an underline, which is the usual style for uncommitted text. Long text is not wrapped and runs to the right edge. The textarea stays invisible, so the overlay is the only visible copy of the composition.

## Considered Options

- **Remove `contenteditable` from the container.** This would stop the stray text node. However, ghostty-web adds the attribute deliberately and its `beforeinput` guard depends on it. Removing it would also leave a focused container that accepts no IME input at all. Sending focus to the textarea fixes the same problem without patching ghostty-web's assumptions.
- **Make the textarea itself visible and styled at the cursor.** This needs no overlay, but browsers style textareas inconsistently (caret, selection highlight, scrollbars, padding). The textarea would also show text the user typed directly and not only the composition. A separate overlay that only displays text is easier to control.
- **Write the preview into the terminal (local echo).** This would mix uncommitted text into the remote application's screen state, and the application would then redraw over it. It is not done.
- **Fix it in ghostty-web upstream.** The upstream issue is [coder/ghostty-web#97](https://github.com/coder/ghostty-web/issues/97), and [coder/ghostty-web#190](https://github.com/coder/ghostty-web/pull/190) fixes the same causes. webtty pins an exact ghostty-web version, so the fix in the client unblocks users now. Once a ghostty-web release includes the upstream fix, bump the pin and remove `src/client/compose.ts`.

## Consequences

- The dictation and IME preview appears at the cursor in the theme's colors, and the layout no longer shifts during composition.
- The system dictation and IME panels follow the cursor.
- The behavior relies on ghostty-web's public `textarea`, `buffer.active` cursor and `renderer.getMetrics()`, which the client already uses for fit, edge extension and kitty graphics. It also assumes that ghostty-web leaves the textarea's position alone except during its right-click copy flow, which the client detects and skips.
- Committed text still goes through ghostty-web's `compositionend` → `onData` path, so its de-duplication against `beforeinput` stays unchanged.
- Verify manually with macOS dictation and at least one CJK IME in Chrome and Safari, and test Linux and Windows IMEs if they are available. The cursor-to-pixel placement is unit-tested in `compose.test.ts`.

## Related Decisions

- [ADR 033 — Padding fit](033.client.padding-fit.md), [ADR 035 — Edge extension](035.client.edge-extend.md): these added the non-canvas areas whose clicks focus the container.
3 changes: 2 additions & 1 deletion docs/specs/client.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SPEC: Client

**Last Updated:** 2026-09-21
**Last Updated:** 2026-10-09

---

Expand Down Expand Up @@ -164,6 +164,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops (
| Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime via client-side intercept | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ |
| Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ |
| Multiline text paste | Bracket text only when mode 2004 is enabled; otherwise send raw text (newlines may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md) | ✅ |
| IME / dictation at cursor | Focus stays on the input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md) | ✅ |
| Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), wheel events are forwarded as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ |
| Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ |
| Canvas gap fill | After each fit, distribute the gap between the container and canvas as symmetric padding so the canvas is centred at the new size | [ADR 022](../adrs/022.client.canvas-fill.md) | ✅ |
Expand Down
34 changes: 34 additions & 0 deletions src/client/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test';
import { computeCursorSpot } from './compose';

const grid = { cols: 80, rows: 24 };
const cell = { width: 9, height: 18 };
const canvas = { left: 0, top: 0 };

describe('computeCursorSpot', () => {
test('puts the spot at the cursor cell', () => {
expect(computeCursorSpot({ x: 10, y: 5 }, 0, grid, cell, canvas)).toEqual({
left: 90,
top: 90,
});
});

test('offsets by the canvas position inside the padding', () => {
expect(computeCursorSpot({ x: 0, y: 0 }, 0, grid, cell, { left: 12, top: 8 })).toEqual({
left: 12,
top: 8,
});
});

test('moves down with the scrollback offset', () => {
expect(computeCursorSpot({ x: 0, y: 5 }, 3, grid, cell, canvas).top).toBe(8 * 18);
});

test('stays on the last row once the cursor scrolls out of view', () => {
expect(computeCursorSpot({ x: 0, y: 20 }, 100, grid, cell, canvas).top).toBe(23 * 18);
});

test('clamps a pending-wrap cursor to the last column', () => {
expect(computeCursorSpot({ x: 80, y: 0 }, 0, grid, cell, canvas).left).toBe(79 * 9);
});
});
181 changes: 181 additions & 0 deletions src/client/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Shows IME / dictation composition text at the terminal cursor. See ADR 038.
*
* ghostty-web makes the container `contenteditable` and reads input from a hidden
* textarea. When the container itself holds focus (a click in the padding, tab focus),
* the browser writes the uncommitted composition into the container as a text node: a
* stray line under the canvas in the page's default colours. When the textarea holds
* focus, the composition is invisible and the OS panels anchor to its spot at the
* top-left corner.
*
* So focus is kept on the textarea, the textarea follows the cursor cell (the OS places
* the dictation and candidate panels by its caret), and an overlay shows the composition
* over the cursor cell in the theme's colours. Committed text still reaches the PTY
* through ghostty-web's own compositionend handling.
*/

import type { Terminal } from 'ghostty-web';

// ghostty-web's defaults for a theme without foreground/background.
const DEFAULT_FOREGROUND = '#d4d4d4';
const DEFAULT_BACKGROUND = '#1e1e1e';

export interface CursorSpot {
left: number;
top: number;
}

/**
* The cursor cell's top-left corner, in CSS pixels relative to the container's padding
* box. `cursorY` is relative to the bottom screen; when scrolled back by `viewportY`
* lines the cursor moves down by that many rows, and it is kept on the last visible row
* once it scrolls out of view.
*/
export function computeCursorSpot(
cursor: { x: number; y: number },
viewportY: number,
grid: { cols: number; rows: number },
cell: { width: number; height: number },
canvas: { left: number; top: number },
): CursorSpot {
const col = Math.min(Math.max(0, cursor.x), Math.max(0, grid.cols - 1));
const row = Math.min(Math.max(0, cursor.y + Math.floor(viewportY)), Math.max(0, grid.rows - 1));
return {
left: canvas.left + col * cell.width,
top: canvas.top + row * cell.height,
};
}

export class Composer {
private readonly overlay = document.createElement('div');
private composing = false;

constructor(
private readonly term: Terminal,
private readonly container: HTMLElement,
private readonly theme: { foreground?: string; background?: string },
) {
const o = this.overlay;
o.dataset.compose = 'true';
o.style.position = 'absolute';
o.style.display = 'none';
o.style.pointerEvents = 'none';
o.style.whiteSpace = 'pre';
o.style.overflow = 'hidden';
o.style.zIndex = '10';
o.style.textDecoration = 'underline';
container.append(o);

container.addEventListener('focusin', this.onFocusIn);
// focus() on a background window moves activeElement without a focusin; catch the
// container still holding focus when the window comes back.
window.addEventListener('focus', () => {
if (document.activeElement === container) this.term.textarea?.focus();
});
container.addEventListener('compositionstart', this.onCompositionStart);
container.addEventListener('compositionupdate', this.onCompositionUpdate);
container.addEventListener('compositionend', this.onCompositionEnd);
container.addEventListener('focusout', this.onCompositionEnd);
// ghostty-web's onRender never fires and onCursorMove only reports row changes, so
// follow the cursor every frame. update() only writes styles that changed.
window.requestAnimationFrame(this.tick);
}

private readonly tick = (): void => {
window.requestAnimationFrame(this.tick);
this.update();
};

/** Re-anchor the textarea (and the overlay, while composing). */
update(): void {
const spot = this.cursorSpot();
const textarea = this.term.textarea;
if (!spot || !textarea) return;
const cell = this.cell();
// ghostty-web's right-click copy flow repositions the textarea while it holds the
// selection; leave it alone until that flow restores it.
if (textarea.style.position === 'fixed' && textarea.style.pointerEvents === 'auto') return;
if (textarea.style.position !== 'absolute') textarea.style.position = 'absolute';
setPx(textarea, 'left', spot.left);
setPx(textarea, 'top', spot.top);
setPx(textarea, 'width', 1);
setPx(textarea, 'height', cell.height);
if (this.composing) this.placeOverlay(spot);
}

private readonly onFocusIn = (e: FocusEvent): void => {
// The container is contenteditable; never let it hold focus, or the browser writes
// composition text into it.
if (e.target === this.container) this.term.textarea?.focus();
};

private readonly onCompositionStart = (): void => {
this.composing = true;
this.update();
};

private readonly onCompositionUpdate = (e: CompositionEvent): void => {
this.composing = true;
const text = e.data ?? '';
if (!text) {
this.overlay.style.display = 'none';
return;
}
this.overlay.textContent = text;
this.update();
this.overlay.style.display = 'block';
};

private readonly onCompositionEnd = (): void => {
this.composing = false;
this.overlay.style.display = 'none';
this.overlay.textContent = '';
};

private placeOverlay(spot: CursorSpot): void {
const o = this.overlay;
const cell = this.cell();
const canvas = this.terminalCanvas();
const right = canvas ? canvas.offsetLeft + canvas.offsetWidth : spot.left;
setPx(o, 'left', spot.left);
setPx(o, 'top', spot.top);
setPx(o, 'height', cell.height);
setPx(o, 'lineHeight', cell.height);
setPx(o, 'maxWidth', Math.max(cell.width, right - spot.left));
o.style.fontFamily = this.term.options.fontFamily;
o.style.fontSize = `${this.term.options.fontSize}px`;
o.style.color = this.theme.foreground ?? DEFAULT_FOREGROUND;
o.style.background = this.theme.background ?? DEFAULT_BACKGROUND;
}

private cursorSpot(): CursorSpot | null {
const canvas = this.terminalCanvas();
if (!canvas) return null;
const buf = this.term.buffer.active;
return computeCursorSpot(
{ x: buf.cursorX, y: buf.cursorY },
this.term.viewportY,
{ cols: this.term.cols, rows: this.term.rows },
this.cell(),
{ left: canvas.offsetLeft, top: canvas.offsetTop },
);
}

private cell(): { width: number; height: number } {
const metrics = this.term.renderer?.getMetrics();
return { width: metrics?.width ?? 0, height: metrics?.height ?? 0 };
}

private terminalCanvas(): HTMLCanvasElement | null {
return this.container.querySelector('canvas:not([data-edge])');
}
}

function setPx(
el: HTMLElement,
prop: 'left' | 'top' | 'width' | 'height' | 'lineHeight' | 'maxWidth',
value: number,
): void {
const px = `${value}px`;
if (el.style[prop] !== px) el.style[prop] = px;
}
4 changes: 4 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { init, Terminal } from 'ghostty-web';
import { Composer } from './compose';
import { applyDecscusr } from './cursor';
import { EdgeExtender } from './edge';
import { computeGrid, computeInsets } from './fit';
Expand Down Expand Up @@ -88,6 +89,9 @@ if (paddingColor) {
}
await term.open(container);
const edges = new EdgeExtender(container, config.padding);
// IME / dictation composition shows at the cursor in the theme's colours, not as a
// stray line under the canvas. See ADR 038.
new Composer(term, container, terminalTheme);

// ghostty-web's FitAddon subtracts a fixed 15px scrollbar reserve, which leaves a
// strip at the edges even though the scrollbar is an overlay drawn inside the canvas.
Expand Down
Loading