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
29 changes: 29 additions & 0 deletions src/tests/promptInputKeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import {
formatImageAttachmentStatus,
formatSelectedSkillsStatus,
getPromptCursorPlacement,
getPromptReturnKeyAction,
isClearImageAttachmentsShortcut,
parseTerminalInput,
removeCurrentSlashToken,
toggleSkillSelection,
renderBufferWithCursor,
buildInitPromptSubmission,
disableTerminalExtendedKeys,
enableTerminalExtendedKeys,
} from "../ui";
import type { SkillInfo } from "../session";

Expand Down Expand Up @@ -83,6 +86,32 @@ test("parseTerminalInput recognizes shifted return sequences", () => {
assert.equal(key.meta, false);
});

test("prompt return key action submits on plain enter", () => {
const { key } = parseTerminalInput("\r");
assert.equal(getPromptReturnKeyAction(key), "submit");
});

test("prompt return key action inserts newline on shift+enter", () => {
const { key } = parseTerminalInput("\u001B[13;2u");
assert.equal(key.return, true);
assert.equal(key.shift, true);
assert.equal(getPromptReturnKeyAction(key), "newline");
});

test("parseTerminalInput recognizes alternate shifted return sequences", () => {
for (const sequence of ["\u001B[13;2~", "\u001B[27;2;13~"]) {
const { key } = parseTerminalInput(sequence);
assert.equal(key.return, true);
assert.equal(key.shift, true);
assert.equal(getPromptReturnKeyAction(key), "newline");
}
});

test("terminal extended key helpers request and restore modifyOtherKeys mode", () => {
assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m");
assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m");
});

test("parseTerminalInput recognizes terminal focus events", () => {
const focusIn = parseTerminalInput("\u001B[I");
const focusOut = parseTerminalInput("\u001B[O");
Expand Down
32 changes: 23 additions & 9 deletions src/ui/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export type { InputKey } from "./prompt";

import { useTerminalInput } from "./prompt";
import type { InputKey } from "./prompt";
import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt";
import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt";
import SlashCommandMenu from "./SlashCommandMenu";
import type { ModelConfigSelection, ReasoningEffort } from "../settings";

Expand Down Expand Up @@ -140,6 +140,7 @@ export const PromptInput = React.memo(function PromptInput({
: "esc to interrupt · ctrl+c to cancel input"
: "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit";
useTerminalFocusReporting(stdout, !disabled);
useTerminalExtendedKeys(stdout, !disabled);
useHiddenTerminalCursor(stdout, !disabled);

useEffect(() => {
Expand Down Expand Up @@ -324,7 +325,8 @@ export const PromptInput = React.memo(function PromptInput({
}

const noModifier = !key.shift && !key.ctrl && !key.meta;
const isPlainReturn = key.return && !key.shift && !key.meta;
const returnAction = getPromptReturnKeyAction(key);
const isPlainReturn = returnAction === "submit";

if (showMenu) {
if (key.upArrow) {
Expand All @@ -335,7 +337,7 @@ export const PromptInput = React.memo(function PromptInput({
setMenuIndex((idx) => (idx + 1) % slashMenu.length);
return;
}
if (key.tab || (key.return && !key.shift && !key.meta)) {
if (key.tab || returnAction === "submit") {
const selected = slashMenu[menuIndex];
if (selected) {
handleSlashSelection(selected);
Expand All @@ -349,12 +351,12 @@ export const PromptInput = React.memo(function PromptInput({
return;
}

if (key.return) {
const isShiftEnter = key.shift || key.meta;
if (isShiftEnter) {
updateBuffer((s) => insertText(s, "\n"));
return;
}
if (returnAction === "newline") {
updateBuffer((s) => insertText(s, "\n"));
return;
}

if (returnAction === "submit") {
submitCurrentBuffer();
return;
}
Expand Down Expand Up @@ -844,6 +846,18 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick<InputKe
return key.ctrl && (input === "x" || input === "X");
}

export type PromptReturnKeyAction = "submit" | "newline" | null;

export function getPromptReturnKeyAction(key: Pick<InputKey, "return" | "shift" | "meta">): PromptReturnKeyAction {
if (!key.return) {
return null;
}
if (key.shift || key.meta) {
return "newline";
}
return "submit";
}

export function renderBufferWithCursor(state: PromptBufferState, isFocused: boolean, placeholder?: string): string {
const text = state.text || "";
const cursor = Math.max(0, Math.min(state.cursor, text.length));
Expand Down
3 changes: 2 additions & 1 deletion src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
toggleSkillSelection,
removeCurrentSlashToken,
isClearImageAttachmentsShortcut,
getPromptReturnKeyAction,
renderBufferWithCursor,
buildInitPromptSubmission,
getThinkingOptionIndex,
Expand All @@ -28,7 +29,7 @@ export {
type PromptSubmission,
type InputKey,
} from "./PromptInput";
export { getPromptCursorPlacement } from "./prompt/cursor";
export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor";
export { SessionList, formatSessionTitle } from "./SessionList";
export { ThemedGradient } from "./ThemedGradient";
export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt";
Expand Down
21 changes: 21 additions & 0 deletions src/ui/prompt/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ function disableTerminalFocusReporting(): string {
return "\u001B[?1004l";
}

export function enableTerminalExtendedKeys(): string {
return "\u001B[>4;1m";
}

export function disableTerminalExtendedKeys(): string {
return "\u001B[>4;0m";
}

export function getPromptCursorPlacement(
state: PromptBufferState,
screenWidth: number,
Expand Down Expand Up @@ -239,3 +247,16 @@ export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined
};
}, [isActive, stdout]);
}

export function useTerminalExtendedKeys(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void {
useLayoutEffect(() => {
if (!isActive || !stdout?.isTTY) {
return;
}

stdout.write(enableTerminalExtendedKeys());
return () => {
stdout.write(disableTerminalExtendedKeys());
};
}, [isActive, stdout]);
}
1 change: 1 addition & 0 deletions src/ui/prompt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type { InputKey } from "./useTerminalInput";

export {
useHiddenTerminalCursor,
useTerminalExtendedKeys,
usePromptTerminalCursor,
useTerminalFocusReporting,
getPromptCursorPlacement,
Expand Down
2 changes: 1 addition & 1 deletion src/ui/prompt/useTerminalInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const BACKSPACE_BYTES = new Set(["\u007F", "\b"]);
const FORWARD_DELETE_SEQUENCES = new Set(["\u001B[3~", "\u001B[P"]);
const HOME_SEQUENCES = new Set(["\u001B[H", "\u001B[1~", "\u001B[7~", "\u001BOH"]);
const END_SEQUENCES = new Set(["\u001B[F", "\u001B[4~", "\u001B[8~", "\u001BOF"]);
const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u"]);
const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u", "\u001B[13;2~", "\u001B[27;2;13~"]);
const META_RETURN_SEQUENCES = new Set(["\u001B[13;3u", "\u001B[13;4u"]);
const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]);
const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]);
Expand Down