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
96 changes: 96 additions & 0 deletions docs/macos-native-editing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# macOS native editing proposal

This proposal lets macOS users edit a Codex draft with the usual Command keys
without assigning unrelated terminal keys to canvas actions.

## Keyboard and clipboard behavior

When a terminal or editable field has focus, its native keyboard handler receives
keys such as F2 and Home. F2 can therefore open the CLI's error report without
also renaming the canvas window. Existing canvas shortcuts still work when the
canvas has focus. This focus rule applies on macOS only.

- Command+C copies an xterm output selection. With no output selection in a
Codex terminal, it sends Super+C to the draft editor.
- Command+V pastes text through xterm's bracketed-paste support. An image in the
native clipboard instead sends Control+V so a CLI supporting image paste can
read it. An asynchronous clipboard read is discarded if its terminal was
closed, exited or restarted before the read completed.
- Command+A sends Super+A to the Codex draft editor. It selects the whole draft
with the optional frontend below, including lines outside the viewport; it
does not select terminal scrollback.
- Codex Enter, Shift+Enter and Command+Enter retain distinct terminal events.
With the optional frontend, Enter and Shift+Enter add a newline to the draft;
only Command+Enter submits (or queues during a running turn). This also applies
to the provisional startup draft. Enter still confirms trust, resume and other
dialogs, and embedded question editors retain their existing keys.
- Command+A in ordinary application inputs, textareas and contenteditable fields
keeps native field selection. Physical key codes also support non-Latin
keyboard layouts. Additional modifier combinations keep their existing path.

## Optional Codex frontend

**Stock Codex 0.159.2 does not implement whole-draft selection.** The renderer
bridge alone cannot provide it. The demonstrated implementation is a separate
[Codex TUI frontend](https://github.com/mrcertis/codex-macos-tui/tree/f532966e68688bda6816ea2b6ae2bf921a5f53f5/macos)
based on official Codex `rust-v0.159.2`. Its launcher requires official
`codex-cli 0.159.2` as the backend and refuses other backend versions.

CanvasTTY continues resolving the official CLI from its existing provider
registry. An optional macOS bundle can include these resources:

```text
Contents/Resources/codex-native-tui/
canvastty-codex-tui
codex-tui-launch.mjs
LICENSE
```

Both frontend and launcher must be present; a partial bundle gives an explicit
launch error. With neither resource present, the usual CLI launch is used. The
standard build does not download or include the external frontend automatically.
The [build and packaging recipe](https://github.com/mrcertis/codex-macos-tui/blob/f532966e68688bda6816ea2b6ae2bf921a5f53f5/macos/README.md#canvastty-170-integration)
uses adjacent CanvasTTY and Codex checkouts. On this branch, skip its `git apply`
steps because the integration is already applied. The recipe has been exercised
on Apple Silicon; Intel packaging remains unverified.

For local development, `CANVASTTY_CODEX_TUI_QA` and
`CANVASTTY_CODEX_TUI_LAUNCHER_QA` accept absolute frontend and launcher paths on
macOS. The launcher owns a private local app-server process and cleans up that
process on exit. It preserves interactive approval, sandbox, config and resume
flags. It rejects positional initial prompts and CLI image arguments; image
paste remains interactive. It does not replace the installed official CLI or
change global Codex configuration. Porting to another backend version requires
updating and testing the frontend and launcher together.

The optional frontend's selection and launcher tests live in its linked
repository. A maintainer decision on distribution and ongoing version support
is needed before including that frontend in official CanvasTTY releases.

## Launch-scoped browser configuration

Codex launches carrying inline config use `--no-daemon` so each launch receives
its own browser capability rather than reusing a daemon's earlier configuration.
A successfully spawned PTY retains its unused browser capability while the user
is in a trust dialog or resume picker. Exit, close and failed-spawn cleanup still
revoke the capability; standalone unused capabilities keep their existing TTL.
Identity, one-time token use and reconnection checks remain in place.

## Regression checks

```sh
node --test tests/terminal-shortcuts.test.mjs tests/app-shortcuts.test.mjs \
tests/canvas-navigation-override.test.mjs tests/terminal-lifecycle.test.mjs \
tests/terminal-launch.test.mjs tests/orchestration-launch-role.test.mjs \
tests/agent-browser-protocol-gateway.test.mjs
npm test
npm run test:even
npm run typecheck
npm run build
npm run audit:secrets
```

The checks cover modifier/layout routing, clipboard reads across session
changes, bundle resolution and delayed browser handshakes. Complete draft
selection also needs verification in the separately built frontend. GUI checks
should type and edit drafts without submitting them.
10 changes: 8 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,10 @@ function createWindow(): { window: BrowserWindow; startupPage: StartupPageLoad }
const currentUrl = window.webContents.getURL();
if (currentUrl && url !== currentUrl) event.preventDefault();
});
canvasNavigationInput?.attach(window.webContents, { preventMouseBindings: false });
canvasNavigationInput?.attach(window.webContents, {
preventMouseBindings: false,
captureMacEditShortcuts: process.platform === "darwin"
});
// Crash recovery: a dead renderer must never leave the user staring at a
// blank window. The application surface is reloaded in place — the same entry
// startup loads — so services, sessions and their scrollback stay untouched
Expand Down Expand Up @@ -378,7 +381,10 @@ async function initializeServices(): Promise<void> {
}
);
if (mainWindow && !mainWindow.isDestroyed()) {
canvasNavigationInput.attach(mainWindow.webContents, { preventMouseBindings: false });
canvasNavigationInput.attach(mainWindow.webContents, {
preventMouseBindings: false,
captureMacEditShortcuts: process.platform === "darwin"
});
}

browserService = new BrowserService(() => mainWindow, {
Expand Down
1 change: 1 addition & 0 deletions src/main/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export function registerIpc({
};

ipcMain.handle(IPC.clipboardRead, () => clipboard.readText());
ipcMain.handle(IPC.clipboardHasImage, () => !clipboard.readImage().isEmpty());
ipcMain.on(IPC.clipboardWrite, (_event, text: string) => {
if (typeof text === "string" && text.length > 0) clipboard.writeText(text);
});
Expand Down
17 changes: 14 additions & 3 deletions src/main/services/CanvasNavigationOverride.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface CanvasNavigationOverrideState {

export interface CanvasNavigationInputAttachmentOptions {
preventMouseBindings?: boolean;
captureMacEditShortcuts?: boolean;
}

export function shouldPreventCanvasNavigationInput(
Expand Down Expand Up @@ -220,7 +221,7 @@ export class CanvasNavigationInputController {
attach(contents: WebContents, options: CanvasNavigationInputAttachmentOptions = {}): void {
if (this.attachedContents.has(contents)) return;
this.attachedContents.add(contents);
contents.on("before-input-event", (event, input) => this.handleInput(contents, event, input));
contents.on("before-input-event", (event, input) => this.handleInput(contents, event, input, options));
contents.on("before-mouse-event", (event, input) => {
const transition = this.handleMouseInput(input);
if (options.preventMouseBindings !== false && transition.reserved) event.preventDefault();
Expand Down Expand Up @@ -278,7 +279,12 @@ export class CanvasNavigationInputController {
);
}

private handleInput(contents: WebContents, event: Event, input: Input): void {
private handleInput(
contents: WebContents,
event: Event,
input: Input,
options: CanvasNavigationInputAttachmentOptions
): void {
if (input.type !== "keyDown" && input.type !== "keyUp") return;
const keyboardInput = {
type: input.type,
Expand All @@ -292,8 +298,13 @@ export class CanvasNavigationInputController {
const previous = this.state();
const wheelTransition = this.wheelTracker.update(keyboardInput);
const navigationTransition = this.navigationTracker.update(keyboardInput);
const macEditShortcut = options.captureMacEditShortcuts === true
&& input.type === "keyDown"
&& input.meta && !input.control && !input.alt && !input.shift
&& (input.code === "KeyA" || input.code === "KeyC" || input.code === "KeyV");
const shouldCaptureMenuShortcuts = this.wheelTracker.shouldCaptureMenuShortcuts
|| this.navigationTracker.shouldCaptureMenuShortcuts;
|| this.navigationTracker.shouldCaptureMenuShortcuts
|| macEditShortcut;
this.setMenuShortcutCapture(contents, shouldCaptureMenuShortcuts);
const shouldPrevent = shouldPreventCanvasNavigationInput(input, {
active: wheelTransition.active || navigationTransition.active,
Expand Down
1 change: 1 addition & 0 deletions src/main/services/TerminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,7 @@ export class TerminalManager {
}

private bindProcess(id: string, session: ManagedSession, process: IPty): void {
session.agentBrowser?.retainUntilExit?.();
process.onData((data) => {
const current = this.sessions.get(id);
if (!current || current !== session || current.process !== process) return;
Expand Down
4 changes: 4 additions & 0 deletions src/main/services/agent-browser/AgentBrowserBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface PreparedAgentBrowserPtyLaunch {
connectionId: string;
args: string[];
environment: Record<string, string>;
retainUntilExit?(): void;
cleanup(): void;
}

Expand Down Expand Up @@ -101,6 +102,9 @@ export class AgentBrowserBridge implements AgentBrowserLaunchCoordinator {
[AGENT_BROWSER_ENV.provider]: capability.provider,
[AGENT_BROWSER_ENV.capabilityToken]: capability.capabilityToken
},
retainUntilExit: () => {
if (!cleaned) this.gateway.holdPendingForTerminal(capability.connectionId);
},
cleanup: () => {
if (cleaned) return;
cleaned = true;
Expand Down
6 changes: 6 additions & 0 deletions src/main/services/agent-browser/AgentGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,12 @@ export class AgentGateway {
};
}

/** A live PTY may wait in the CLI's resume or trust UI before its first MCP handshake. */
holdPendingForTerminal(connectionId: string): void {
const lease = this.leases.get(connectionId);
if (lease && !lease.used) lease.expiresAt = Infinity;
}

revokeTerminalSession(terminalSessionId: string): void {
for (const [connectionId, lease] of this.leases) {
if (lease.actor.terminalSessionId !== terminalSessionId) continue;
Expand Down
25 changes: 24 additions & 1 deletion src/main/services/terminalLaunch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
import { win32 } from "node:path";
import { isAbsolute, posix, win32 } from "node:path";
import type { ProviderId } from "../../shared/contracts.ts";
import { normalizeThreadId } from "../../agent-runtime/runtime-protocol.mjs";
import { openCodeYoloEnvironment } from "./openCodeConfig.ts";
Expand All @@ -20,6 +20,7 @@ interface LaunchResolutionOptions {
platform?: NodeJS.Platform;
environment?: Readonly<NodeJS.ProcessEnv>;
fileExists?: (path: string) => boolean;
resourcesPath?: string;
providerCli?: ProviderCliResolution;
resumePrevious?: boolean;
resumeThreadId?: string;
Expand Down Expand Up @@ -58,6 +59,7 @@ export function resolveTerminalLaunch(
: undefined;
const auto = profile === "auto";
const providerArgs = [
...(provider === "codex" && agentBrowserArgs.includes("-c") ? ["--no-daemon"] : []),
...(profile === "yolo" && provider !== "opencode" ? DANGEROUS_ARGUMENTS[provider] : []),
...(auto ? autoModeArguments(provider, options.thirdPartyModel === true) : []),
// Claude Code keeps only the last inline --settings: a plugin's (after the hooks') would silently drop the hooks.
Expand All @@ -71,6 +73,27 @@ export function resolveTerminalLaunch(
...providerCli.environment,
...launchEnvironment
};
const resourcesPath = options.resourcesPath ?? process.resourcesPath;
const bundledDirectory = provider === "codex" && platform === "darwin" && resourcesPath
? posix.join(resourcesPath, "codex-native-tui") : undefined;
const bundledFrontend = bundledDirectory ? posix.join(bundledDirectory, "canvastty-codex-tui") : undefined;
const bundledLauncher = bundledDirectory ? posix.join(bundledDirectory, "codex-tui-launch.mjs") : undefined;
const qaFrontend = environment.CANVASTTY_CODEX_TUI_QA;
const frontend = qaFrontend || (bundledFrontend && bundledLauncher
&& (fileExists(bundledFrontend) || fileExists(bundledLauncher)) ? bundledFrontend : undefined);
if (provider === "codex" && frontend) {
const launcher = qaFrontend ? environment.CANVASTTY_CODEX_TUI_LAUNCHER_QA : bundledLauncher;
if (platform !== "darwin" || providerCli.launcher !== "native"
|| !isAbsolute(frontend) || !fileExists(frontend)
|| !launcher || !isAbsolute(launcher) || !fileExists(launcher)) {
throw new Error("Codex native TUI requires existing absolute frontend and launcher paths on macOS.");
}
return {
command: process.execPath,
args: [launcher, "--backend", providerCli.executable, "--frontend", frontend, "--", ...providerArgs],
environment: { ...combinedEnvironment, ELECTRON_RUN_AS_NODE: "1" }
};
}
if (providerCli.launcher === "native") {
return {
command: providerCli.executable,
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const api: CanvasTTYApi = {
appVersion: () => ipcRenderer.invoke(IPC.appVersion),
clipboard: {
readText: () => ipcRenderer.invoke(IPC.clipboardRead),
hasImage: () => ipcRenderer.invoke(IPC.clipboardHasImage),
writeText: (text: string) => ipcRenderer.send(IPC.clipboardWrite, text)
},
external: {
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@ import {
upsertSnapshot
} from "./lib/sessionReconciliation";
import {
handleMacNativeSelectAll,
isRenameInputTarget,
isShortcutCaptureTarget,
matchesPointerShortcut,
matchesShortcut
matchesShortcut,
shouldKeepNativeKeyboardInput
} from "./lib/shortcuts";
import { homeGridPixelSize, homeLayoutFitsGrid, placeHomeWidget } from "./features/home/homeLayout";
import { boundsInsideRegion, translateBounds } from "./features/workspace/canvasRegions";
Expand Down Expand Up @@ -1125,6 +1127,8 @@ export function App(): React.JSX.Element {
setRenamingSessionId(activeSessionId);
};
const handleShortcut = (event: KeyboardEvent): void => {
if (handleMacNativeSelectAll(event, window.canvasTTY.window.isMacOS)) return;
if (shouldKeepNativeKeyboardInput(event.target, window.canvasTTY.window.isMacOS)) return;
if (event.repeat || isShortcutCaptureTarget(event.target) || isRenameInputTarget(event.target)) return;
if (matchesShortcut(event, settings.shortcuts.toggleFullscreen)) {
event.preventDefault();
Expand Down
45 changes: 39 additions & 6 deletions src/renderer/src/features/terminal/TerminalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ import { isCustomTerminalBorderSkinId, terminalBorderSkinFallback } from "../../
import { sessionStatusLabel } from "../../lib/sessionStatus";
import { attachTerminalMouseCoordinateAdapter, attachTerminalScrollbarCoordinateAdapter } from "./terminalMouseCoordinates";
import {
CODEX_SELECT_ALL_SEQUENCE,
SHIFT_ENTER_SEQUENCE,
isMacTerminalClipboardShortcut,
macCodexEnterSequence,
shouldCopyTerminalSelection,
shouldPasteTerminalClipboard,
shouldRestartExitedTerminal,
shouldScrollTerminalPage,
shouldSearchTerminalOutput,
shouldSelectCodexDraft,
shouldSendTerminalLineBreak
} from "./terminalShortcuts";
import { attachTerminalRedrawViewport, fitTerminalPreservingViewport } from "./terminalViewport";
Expand Down Expand Up @@ -174,6 +178,8 @@ function TerminalCardView({
const suppressFocusReport = useRef(false);
const sessionExited = useRef(session.exitCode !== null);
sessionExited.current = session.exitCode !== null;
const sessionStartedAt = useRef(session.startedAt);
sessionStartedAt.current = session.startedAt;
const restartAction = useRef<(resume?: boolean) => Promise<void>>(async () => undefined);
const invertTerminalWheelRef = useRef(invertTerminalWheel);
invertTerminalWheelRef.current = invertTerminalWheel;
Expand Down Expand Up @@ -332,6 +338,22 @@ function TerminalCardView({
};
fitRef.current = fit;
terminal.attachCustomKeyEventHandler((event) => {
const codexEnter = macCodexEnterSequence(event, window.canvasTTY.window.isMacOS, session.provider);
if (codexEnter !== null) {
event.preventDefault();
event.stopPropagation();
window.canvasTTY.terminal.input(session.id, codexEnter);
return false;
}
const selectCodexDraft = shouldSelectCodexDraft(event, window.canvasTTY.window.isMacOS, session.provider);
if (window.canvasTTY.window.isMacOS && !isMacTerminalClipboardShortcut(event) && !selectCodexDraft) return true;
if (selectCodexDraft) {
event.preventDefault();
event.stopPropagation();
terminal.clearSelection();
window.canvasTTY.terminal.input(session.id, CODEX_SELECT_ALL_SEQUENCE);
return false;
}
if ((event.key === "F4" || event.code === "F4")
&& !event.ctrlKey && !event.shiftKey && !event.metaKey && !event.altKey
&& terminalHost.current?.closest(".terminal-card")?.getAttribute("data-pixel-skin") === "true") {
Expand Down Expand Up @@ -370,19 +392,30 @@ function TerminalCardView({
terminal.scrollPages(pageDirection);
return false;
}
if (shouldCopyTerminalSelection(event, terminal.hasSelection())) {
if (shouldCopyTerminalSelection(event, terminal.hasSelection() || window.canvasTTY.window.isMacOS)) {
event.preventDefault();
event.stopPropagation();
window.canvasTTY.clipboard.writeText(terminal.getSelection());
if (terminal.hasSelection()) window.canvasTTY.clipboard.writeText(terminal.getSelection());
else if (session.provider === "codex") window.canvasTTY.terminal.input(session.id, "\u001b[99;9u");
return false;
}
if (!shouldPasteTerminalClipboard(event)) return true;

event.preventDefault();
event.stopPropagation();
void window.canvasTTY.clipboard.readText()
.then((text) => {
if (text && terminalRef.current === terminal) terminal.paste(text);
const pasteStartedAt = sessionStartedAt.current;
const acceptsPaste = () => terminalRef.current === terminal && !sessionExited.current
&& sessionStartedAt.current === pasteStartedAt;
if (!acceptsPaste()) return false;
void (event.metaKey ? window.canvasTTY.clipboard.hasImage() : Promise.resolve(false))
.then(async (hasImage) => {
if (!acceptsPaste()) return;
if (hasImage) {
window.canvasTTY.terminal.input(session.id, "\u0016");
return;
}
const text = await window.canvasTTY.clipboard.readText();
if (text && acceptsPaste()) terminal.paste(text);
})
.catch(() => undefined);
return false;
Expand Down Expand Up @@ -815,7 +848,7 @@ function TerminalCardView({
onKeyDown={(event) => {
// Fallback for focus parked on the card itself; the terminal textarea is
// handled by attachCustomKeyEventHandler, which stops propagation first.
if (summaryMode || !shouldSearchTerminalOutput(event)) return;
if (window.canvasTTY.window.isMacOS || summaryMode || !shouldSearchTerminalOutput(event)) return;
event.preventDefault();
event.stopPropagation();
toggleSearch();
Expand Down
Loading