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
22 changes: 22 additions & 0 deletions README-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,28 @@ Skills are discovered from these locations, in priority order:
| `Ctrl+V` | Paste an image from the clipboard |
| `Esc` | Interrupt the current model turn |
| `Ctrl+D` twice | Quit Deep Code |
| `←` / `→` | Move the cursor one character |
| `Alt+←` / `Alt+→` (or `Ctrl+←` / `Ctrl+→`) | Move by word |
| `Home` / `End` (or `Ctrl+A` / `Ctrl+E`) | Jump to line start / end |
| `Ctrl+B` / `Ctrl+F` | Move left / right one character |
| `Alt+B` / `Alt+F` | Move back / forward by word |
| `Ctrl+W` | Delete the word before the cursor |
| `Alt+Backspace` | Delete the word before the cursor (macOS) |
| `Alt+D` | Delete the word after the cursor |
| `Ctrl+K` | Delete to the end of the line |
| `Ctrl+U` | Clear the input |
| `Backspace` / `Delete` | Delete the previous / next character |

These editing keys also apply to the "Other" free-text answer in AI question prompts.

### Terminal key mapping notes

`Cmd` (macOS) and `Win` (Windows) key combinations are reserved by the operating system and are never delivered to terminal apps — map them in your terminal emulator instead:

- **Linux + Bash**: use `Ctrl+A` / `Ctrl+E` or `Home` / `End` for line start/end; `Alt+B` / `Alt+F` or `Ctrl+←` / `Ctrl+→` for words.
- **macOS Terminal.app**: use `Ctrl+A` / `Ctrl+E` for line start/end and `Fn+←` / `Fn+→` as Home/End. Enable *Settings → Profiles → Keyboard → Use Option as Meta key* so `Option+B` / `Option+F` move by word.
- **macOS iTerm2**: map `Cmd+←` / `Cmd+→` under *Settings → Profiles → Keys* to send the Home/End escape sequences (`ESC[H` / `ESC[F`) or hex `0x01` / `0x05` (`Ctrl+A` / `Ctrl+E`).
- **Windows Terminal (PowerShell / WSL)**: `Win+←` / `Win+→` is OS window snapping and cannot be used. Use `Home` / `End` or `Ctrl+A` / `Ctrl+E` for line start/end, and `Ctrl+←` / `Ctrl+→` for word movement.

## Supported Models

Expand Down
22 changes: 22 additions & 0 deletions README-zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ Skills 会按以下优先级扫描:
| `Ctrl+V` | 从剪贴板粘贴图片 |
| `Esc` | 中断当前模型回复 |
| 连续 `Ctrl+D` | 退出 |
| `←` / `→` | 光标左右移动一个字符 |
| `Alt+←` / `Alt+→`(或 `Ctrl+←` / `Ctrl+→`) | 按词移动 |
| `Home` / `End`(或 `Ctrl+A` / `Ctrl+E`) | 跳到行首 / 行尾 |
| `Ctrl+B` / `Ctrl+F` | 光标左移 / 右移一个字符 |
| `Alt+B` / `Alt+F` | 按词后退 / 前进 |
| `Ctrl+W` | 删除光标前的单词 |
| `Alt+Backspace` | 删除光标前的单词(macOS) |
| `Alt+D` | 删除光标后的单词 |
| `Ctrl+K` | 删除到行尾 |
| `Ctrl+U` | 清空输入 |
| `Backspace` / `Delete` | 删除前一个 / 后一个字符 |

以上编辑键同样适用于 AI 提问框中 "Other" 自由文本回答。

### 终端按键映射说明

`Cmd`(macOS)与 `Win`(Windows)组合键被操作系统保留,终端应用无法接收,请在终端模拟器中自行映射:

- **Linux + Bash**:行首/行尾用 `Ctrl+A` / `Ctrl+E` 或 `Home` / `End`;按词移动用 `Alt+B` / `Alt+F` 或 `Ctrl+←` / `Ctrl+→`。
- **macOS 终端(Terminal.app)**:行首/行尾用 `Ctrl+A` / `Ctrl+E`,`Fn+←` / `Fn+→` 等同于 Home/End。在 *设置 → 描述文件 → 键盘 → 将 Option 用作 Meta 键* 中启用后,`Option+B` / `Option+F` 可按词移动。
- **macOS iTerm2**:在 *设置 → Profiles → Keys* 中将 `Cmd+←` / `Cmd+→` 映射为发送 Home/End 转义序列(`ESC[H` / `ESC[F`)或十六进制 `0x01` / `0x05`(`Ctrl+A` / `Ctrl+E`)。
- **Windows Terminal(PowerShell / WSL)**:`Win+←` / `Win+→` 为系统窗口贴靠,无法使用。行首/行尾请用 `Home` / `End` 或 `Ctrl+A` / `Ctrl+E`,按词移动用 `Ctrl+←` / `Ctrl+→`。

## 支持的模型

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ Skills 会按以下优先级扫描:
| `Ctrl+V` | 从剪贴板粘贴图片 |
| `Esc` | 中断当前模型回复 |
| 连续 `Ctrl+D` | 退出 |
| `←` / `→` | 光标左右移动一个字符 |
| `Alt+←` / `Alt+→`(或 `Ctrl+←` / `Ctrl+→`) | 按词移动 |
| `Home` / `End`(或 `Ctrl+A` / `Ctrl+E`) | 跳到行首 / 行尾 |
| `Ctrl+B` / `Ctrl+F` | 光标左移 / 右移一个字符 |
| `Alt+B` / `Alt+F` | 按词后退 / 前进 |
| `Ctrl+W` | 删除光标前的单词 |
| `Alt+Backspace` | 删除光标前的单词(macOS) |
| `Alt+D` | 删除光标后的单词 |
| `Ctrl+K` | 删除到行尾 |
| `Ctrl+U` | 清空输入 |
| `Backspace` / `Delete` | 删除前一个 / 后一个字符 |

以上编辑键同样适用于 AI 提问框中 "Other" 自由文本回答。

### 终端按键映射说明

`Cmd`(macOS)与 `Win`(Windows)组合键被操作系统保留,终端应用无法接收,请在终端模拟器中自行映射:

- **Linux + Bash**:行首/行尾用 `Ctrl+A` / `Ctrl+E` 或 `Home` / `End`;按词移动用 `Alt+B` / `Alt+F` 或 `Ctrl+←` / `Ctrl+→`。
- **macOS 终端(Terminal.app)**:行首/行尾用 `Ctrl+A` / `Ctrl+E`,`Fn+←` / `Fn+→` 等同于 Home/End。在 *设置 → 描述文件 → 键盘 → 将 Option 用作 Meta 键* 中启用后,`Option+B` / `Option+F` 可按词移动。
- **macOS iTerm2**:在 *设置 → Profiles → Keys* 中将 `Cmd+←` / `Cmd+→` 映射为发送 Home/End 转义序列(`ESC[H` / `ESC[F`)或十六进制 `0x01` / `0x05`(`Ctrl+A` / `Ctrl+E`)。
- **Windows Terminal(PowerShell / WSL)**:`Win+←` / `Win+→` 为系统窗口贴靠,无法使用。行首/行尾请用 `Home` / `End` 或 `Ctrl+A` / `Ctrl+E`,按词移动用 `Ctrl+←` / `Ctrl+→`。

## 支持的模型

Expand Down
63 changes: 62 additions & 1 deletion packages/cli/src/tests/ask-user-question.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, formatAskUserQuestionDecline } from "../ui";
import {
applyOtherAnswerEdit,
findPendingAskUserQuestion,
formatAskUserQuestionAnswers,
formatAskUserQuestionDecline,
} from "../ui";
import type { PromptBufferState } from "../ui";
import { parseTerminalInput } from "../ui/hooks";
import type { SessionMessage } from "@vegamo/deepcode-core";

function message(content: unknown): SessionMessage {
Expand Down Expand Up @@ -122,3 +129,57 @@ test("formatAskUserQuestionAnswers normalizes multiline questions and answers",
test("formatAskUserQuestionDecline creates decline text", () => {
assert.match(formatAskUserQuestionDecline(), /declined to answer/);
});

function editWith(state: PromptBufferState, sequence: string): PromptBufferState | null {
const { input, key } = parseTerminalInput(sequence);
return applyOtherAnswerEdit(state, input, key);
}

function state(text: string, cursor: number): PromptBufferState {
return { text, cursor };
}

test("applyOtherAnswerEdit inserts typed text at the cursor", () => {
assert.deepEqual(editWith(state("ac", 1), "b"), { text: "abc", cursor: 2 });
});

test("applyOtherAnswerEdit moves the cursor with plain arrow keys and never inserts residue", () => {
const { input, key } = parseTerminalInput("\u001B[D");
assert.equal(input, "[D"); // escape-sequence residue that used to be typed
assert.deepEqual(applyOtherAnswerEdit(state("abc", 2), input, key), { text: "abc", cursor: 1 });
assert.deepEqual(editWith(state("abc", 1), "\u001B[C"), { text: "abc", cursor: 2 });
assert.deepEqual(editWith(state("abc", 2), "\u001B[C"), { text: "abc", cursor: 3 });
});

test("applyOtherAnswerEdit moves by word with ctrl/meta arrows", () => {
const text = state("one two three", 7);
assert.deepEqual(editWith(text, "\u001B[1;5D"), { text: "one two three", cursor: 4 });
assert.deepEqual(editWith(text, "\u001Bb"), { text: "one two three", cursor: 4 });
assert.deepEqual(editWith(state("one two", 0), "\u001B[1;5C"), { text: "one two", cursor: 3 });
assert.deepEqual(editWith(state("one two", 0), "\u001Bf"), { text: "one two", cursor: 3 });
});

test("applyOtherAnswerEdit moves to line start/end with home/end and ctrl+a/e", () => {
assert.deepEqual(editWith(state("abc", 1), "\u001B[H"), { text: "abc", cursor: 0 });
assert.deepEqual(editWith(state("abc", 1), "\u001B[F"), { text: "abc", cursor: 3 });
assert.deepEqual(editWith(state("abc", 1), "\u0001"), { text: "abc", cursor: 0 });
assert.deepEqual(editWith(state("abc", 1), "\u0005"), { text: "abc", cursor: 3 });
assert.deepEqual(editWith(state("abc", 1), "\u001B[1;5H"), { text: "abc", cursor: 0 });
assert.deepEqual(editWith(state("abc", 1), "\u001B[1;5F"), { text: "abc", cursor: 3 });
});

test("applyOtherAnswerEdit deletes around the cursor", () => {
assert.deepEqual(editWith(state("abcd", 2), "\u007F"), { text: "acd", cursor: 1 });
assert.deepEqual(editWith(state("abcd", 2), "\u001B[3~"), { text: "abd", cursor: 2 });
assert.deepEqual(editWith(state("one two", 7), "\u0017"), { text: "one ", cursor: 4 });
assert.deepEqual(editWith(state("abc", 1), "\u0015"), { text: "", cursor: 0 });
assert.deepEqual(editWith(state("ab\ncd", 1), "\u000B"), { text: "a\ncd", cursor: 1 });
assert.deepEqual(editWith(state("one two", 3), "\u001Bd"), { text: "one", cursor: 3 });
assert.deepEqual(editWith(state("one two", 4), "\u001B\u007F"), { text: "two", cursor: 0 });
});

test("applyOtherAnswerEdit ignores unhandled escape sequences instead of typing them", () => {
for (const sequence of ["\u001B[A", "\u001B[B", "\u001B[1;2D", "\u001B[11~", "\u001B[Z"]) {
assert.equal(editWith(state("abc", 1), sequence), null, `sequence ${JSON.stringify(sequence)}`);
}
});
20 changes: 20 additions & 0 deletions packages/cli/src/tests/prompt-input-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ test("parseTerminalInput recognizes word navigation modifiers", () => {
assert.equal(metaRight.key.meta, true);
});

test("parseTerminalInput recognizes application cursor mode arrows", () => {
const left = parseTerminalInput("\u001BOD");
const right = parseTerminalInput("\u001BOC");
assert.equal(left.key.leftArrow, true);
assert.equal(left.key.meta, false);
assert.equal(right.key.rightArrow, true);
assert.equal(right.key.meta, false);
});

test("parseTerminalInput recognizes ctrl+home and ctrl+end", () => {
const home = parseTerminalInput("\u001B[1;5H");
const end = parseTerminalInput("\u001B[1;5F");
assert.equal(home.key.home, true);
assert.equal(home.key.ctrl, true);
assert.equal(home.key.meta, false);
assert.equal(end.key.end, true);
assert.equal(end.key.ctrl, true);
assert.equal(end.key.meta, false);
});

test("parseTerminalInput keeps DEL payload for meta+backspace", () => {
const { input, key } = parseTerminalInput("\u001B\u007F");
assert.equal(input, "\u007F");
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/src/ui/hooks/useTerminalInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]);
const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]);
const META_LEFT_SEQUENCES = new Set(["\u001B[1;3D", "\u001B[3D", "\u001Bb"]);
const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]);
// Application cursor keys mode (DECCKM) arrows.
const APPLICATION_LEFT = "\u001BOD";
const APPLICATION_RIGHT = "\u001BOC";
const CTRL_HOME_SEQUENCES = new Set(["\u001B[1;5H", "\u001B[5H"]);
const CTRL_END_SEQUENCES = new Set(["\u001B[1;5F", "\u001B[5F"]);
const TERMINAL_FOCUS_IN = "\u001B[I";
const TERMINAL_FOCUS_OUT = "\u001B[O";

Expand Down Expand Up @@ -118,15 +123,21 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key:
const key: InputKey = {
upArrow: raw === "\u001B[A",
downArrow: raw === "\u001B[B",
leftArrow: raw === "\u001B[D" || CTRL_LEFT_SEQUENCES.has(raw) || META_LEFT_SEQUENCES.has(raw),
rightArrow: raw === "\u001B[C" || CTRL_RIGHT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw),
home: HOME_SEQUENCES.has(raw),
end: END_SEQUENCES.has(raw),
leftArrow:
raw === "\u001B[D" || raw === APPLICATION_LEFT || CTRL_LEFT_SEQUENCES.has(raw) || META_LEFT_SEQUENCES.has(raw),
rightArrow:
raw === "\u001B[C" || raw === APPLICATION_RIGHT || CTRL_RIGHT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw),
home: HOME_SEQUENCES.has(raw) || CTRL_HOME_SEQUENCES.has(raw),
end: END_SEQUENCES.has(raw) || CTRL_END_SEQUENCES.has(raw),
pageDown: raw === "\u001B[6~",
pageUp: raw === "\u001B[5~",
return: raw === "\r" || SHIFT_RETURN_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw),
escape: raw === "\u001B",
ctrl: CTRL_LEFT_SEQUENCES.has(raw) || CTRL_RIGHT_SEQUENCES.has(raw),
ctrl:
CTRL_LEFT_SEQUENCES.has(raw) ||
CTRL_RIGHT_SEQUENCES.has(raw) ||
CTRL_HOME_SEQUENCES.has(raw) ||
CTRL_END_SEQUENCES.has(raw),
shift: SHIFT_RETURN_SEQUENCES.has(raw),
tab: raw === "\t" || raw === "\u001B[Z",
backspace: BACKSPACE_BYTES.has(raw),
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export {
resolvePromptTerminalCursorPosition,
} from "./hooks/cursor";
export { default as AppContainer } from "./views/AppContainer";
export { AskUserQuestionPrompt } from "./views/AskUserQuestionPrompt";
export { AskUserQuestionPrompt, applyOtherAnswerEdit } from "./views/AskUserQuestionPrompt";
export {
PlanImplementationPrompt,
extractProposedPlan,
Expand Down
Loading
Loading