Skip to content
Open
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
58 changes: 10 additions & 48 deletions src/components/ComposerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { useNotificationStore } from "../stores/notificationStore";
import { useCanvasStore, SIDEBAR_WIDTH, RIGHT_PANEL_WIDTH, COLLAPSED_TAB_WIDTH } from "../stores/canvasStore";
import { getComposerAdapter } from "../terminal/cliConfig";
import { filterSlashCommands } from "../terminal/slashCommands";
import { shouldSubmitComposerFromKeyEvent } from "./composerInputBehavior";
import {
getComposerPassthroughSequence,
shouldSubmitComposerFromKeyEvent,
} from "./composerInputBehavior";
import {
getComposerTargetState,
getSupportedTerminals,
Expand Down Expand Up @@ -69,52 +72,6 @@ function formatComposerFailure(
return t.composer_submit_failed_with_context(targetTitle, stage, detail);
}

const ARROW_SEQUENCES: Record<string, string> = {
ArrowUp: "\x1b[A",
ArrowDown: "\x1b[B",
ArrowRight: "\x1b[C",
ArrowLeft: "\x1b[D",
};

/**
* Map keyboard events to terminal escape sequences for keys that should be
* forwarded to the PTY rather than handled by the Composer textarea.
* Returns null if the key should stay in the textarea.
*/
function getPassthroughSequence(
event: React.KeyboardEvent<HTMLTextAreaElement>,
draft: string,
hasImages: boolean,
): string | null {
// Shift+Tab → mode cycling (e.g. Claude Code permission modes)
if (event.key === "Tab" && event.shiftKey) return "\x1b[Z";
// Escape → cancel / go back
if (event.key === "Escape") return "\x1b";
// Ctrl+C → interrupt (only when no text is selected, so copy still works)
if (event.key === "c" && event.ctrlKey && !event.metaKey) {
const el = event.target as HTMLTextAreaElement;
if (el.selectionStart === el.selectionEnd) {
return "\x03";
}
}
// Enter → forward to terminal when Composer has no content
// (e.g. confirm permission prompts, accept defaults)
if (event.key === "Enter" && !event.shiftKey && draft.trim().length === 0 && !hasImages) {
return "\r";
}
// Backspace → forward to terminal when Composer is empty
if (event.key === "Backspace" && draft.length === 0 && !hasImages) {
return "\x7f";
}
// Cmd+Arrow → always forward to terminal (history / cursor control)
// Plain Arrow → forward only when Composer is empty
const arrowSeq = ARROW_SEQUENCES[event.key];
if (arrowSeq && (event.metaKey || draft.trim().length === 0)) {
return arrowSeq;
}
return null;
}

export function ComposerBar() {
const t = useT();
const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -696,7 +653,12 @@ export function ComposerBar() {
}

if (targetTerminal) {
const seq = getPassthroughSequence(event, draft, images.length > 0);
const seq = getComposerPassthroughSequence(
event,
draft,
images.length > 0,
window.termcanvas?.app.platform ?? "darwin",
);
if (seq !== null) {
event.preventDefault();
window.termcanvas.terminal.input(targetTerminal.ptyId, seq);
Expand Down
53 changes: 53 additions & 0 deletions src/components/composerInputBehavior.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { hasPrimaryModifier } from "../hooks/shortcutTarget.ts";

export interface ComposerKeyEventLike {
key: string;
shiftKey: boolean;
Expand All @@ -7,6 +9,24 @@ export interface ComposerKeyEventLike {
};
}

export interface ComposerPassthroughKeyEventLike {
key: string;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
target?: {
selectionStart: number;
selectionEnd: number;
} | null;
}

const ARROW_SEQUENCES: Record<string, string> = {
ArrowUp: "\x1b[A",
ArrowDown: "\x1b[B",
ArrowRight: "\x1b[C",
ArrowLeft: "\x1b[D",
};

export function shouldSubmitComposerFromKeyEvent(
event: ComposerKeyEventLike,
): boolean {
Expand All @@ -20,3 +40,36 @@ export function shouldSubmitComposerFromKeyEvent(

return !(event.isComposing || event.nativeEvent?.isComposing);
}

export function getComposerPassthroughSequence(
event: ComposerPassthroughKeyEventLike,
draft: string,
hasImages: boolean,
platform: "darwin" | "win32" | "linux" = "darwin",
): string | null {
if (event.key === "Tab" && event.shiftKey) return "\x1b[Z";
if (event.key === "Escape") return "\x1b";

if (event.key === "c" && event.ctrlKey && !event.metaKey) {
const selectionStart = event.target?.selectionStart;
const selectionEnd = event.target?.selectionEnd;
if (selectionStart === undefined || selectionStart === selectionEnd) {
return "\x03";
}
}

if (event.key === "Enter" && !event.shiftKey && draft.trim().length === 0 && !hasImages) {
return "\r";
}

if (event.key === "Backspace" && draft.length === 0 && !hasImages) {
return "\x7f";
}

const arrowSeq = ARROW_SEQUENCES[event.key];
if (arrowSeq && (hasPrimaryModifier(event, platform) || draft.trim().length === 0)) {
return arrowSeq;
}

return null;
}
39 changes: 38 additions & 1 deletion tests/composer-input-behavior.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";

import { shouldSubmitComposerFromKeyEvent } from "../src/components/composerInputBehavior.ts";
import {
getComposerPassthroughSequence,
shouldSubmitComposerFromKeyEvent,
} from "../src/components/composerInputBehavior.ts";

test("Enter submits the composer", () => {
assert.equal(
Expand Down Expand Up @@ -35,3 +38,37 @@ test("Composing text with an IME does not submit on Enter", () => {
false,
);
});

test("Windows Ctrl+Arrow forwards to terminal even with draft text", () => {
assert.equal(
getComposerPassthroughSequence(
{
key: "ArrowUp",
shiftKey: false,
ctrlKey: true,
metaKey: false,
},
"hello",
false,
"win32",
),
"\x1b[A",
);
});

test("Windows plain Arrow stays in composer when draft has text", () => {
assert.equal(
getComposerPassthroughSequence(
{
key: "ArrowUp",
shiftKey: false,
ctrlKey: false,
metaKey: false,
},
"hello",
false,
"win32",
),
null,
);
});