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
70 changes: 70 additions & 0 deletions src/edit-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type readline from "node:readline";

export type EditAction =
| "word-left"
| "word-right"
| "line-start"
| "line-end"
| "delete-word"
| "delete-line"
| null;

const isWordChar = (char: string | undefined): boolean =>
char !== undefined && char !== "\n" && !/\s/.test(char);

/** Index of the start of the word before `cursor` (readline backward-word). */
export function wordStart(chars: readonly string[], cursor: number): number {
let index = Math.min(cursor, chars.length);
while (index > 0 && !isWordChar(chars[index - 1]) && chars[index - 1] !== "\n") index -= 1;
if (index > 0 && chars[index - 1] === "\n" && index === cursor) return index - 1;
while (index > 0 && isWordChar(chars[index - 1])) index -= 1;
return index;
}

/** Index of the end of the word after `cursor` (readline forward-word). */
export function wordEnd(chars: readonly string[], cursor: number): number {
let index = Math.max(0, cursor);
while (index < chars.length && !isWordChar(chars[index]) && chars[index] !== "\n") index += 1;
if (index < chars.length && chars[index] === "\n" && index === cursor) return index + 1;
while (index < chars.length && isWordChar(chars[index])) index += 1;
return index;
}

export function lineStart(chars: readonly string[], cursor: number): number {
let index = cursor;
while (index > 0 && chars[index - 1] !== "\n") index -= 1;
return index;
}

export function lineEnd(chars: readonly string[], cursor: number): number {
let index = cursor;
while (index < chars.length && chars[index] !== "\n") index += 1;
return index;
}

/**
* Map a readline keypress to an editing action beyond single-character moves.
*
* Terminals encode Option/Alt as xterm modifier 3 and Command/Super as 9; Node's
* readline folds both into `key.meta`, so the raw sequence tells them apart.
* Ghostty (and most macOS terminals) rewrite Cmd+Backspace to Ctrl+U and
* Opt+Backspace to Ctrl+W, matching the readline line/word kill bindings.
*/
export function resolveEditKey(key: readline.Key): EditAction {
const sequence = key.sequence ?? "";
const superModifier = /;9[A-Z~]$/.test(sequence);

if (key.ctrl && key.name === "u") return "delete-line";
if (key.ctrl && key.name === "w") return "delete-word";
if (key.meta && key.name === "backspace") return "delete-word";
if (key.ctrl && key.name === "a") return "line-start";
if (key.ctrl && key.name === "e") return "line-end";

if (key.meta && key.name === "b") return "word-left";
if (key.meta && key.name === "f") return "word-right";

if (key.name === "left" && (key.meta || key.ctrl)) return superModifier ? "line-start" : "word-left";
if (key.name === "right" && (key.meta || key.ctrl)) return superModifier ? "line-end" : "word-right";

return null;
}
20 changes: 19 additions & 1 deletion src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import readline from "node:readline";
import { sanitizeTerminalText, wrapText } from "./format";
import { stateDir } from "./paths";
import { layoutComment } from "./layout";
import { lineEnd, lineStart, resolveEditKey, wordEnd, wordStart } from "./edit-keys";
import { charWidth, stringWidth, truncateToWidth } from "./width";
import type { StoreResult } from "./store";
import {
Expand Down Expand Up @@ -177,7 +178,24 @@ process.stdin.on("keypress", (text: string, key: readline.Key) => {
return;
}
if (key.name === "escape") return exit(0);
if (key.name === "backspace") {
const action = resolveEditKey(key);
if (action === "word-left") {
cursor = wordStart(comment, cursor);
} else if (action === "word-right") {
cursor = wordEnd(comment, cursor);
} else if (action === "line-start") {
cursor = lineStart(comment, cursor);
} else if (action === "line-end") {
cursor = lineEnd(comment, cursor);
} else if (action === "delete-word") {
const start = wordStart(comment, cursor);
comment.splice(start, cursor - start);
cursor = start;
} else if (action === "delete-line") {
const start = lineStart(comment, cursor);
comment.splice(start, cursor - start);
cursor = start;
} else if (key.name === "backspace") {
if (cursor > 0) comment.splice(--cursor, 1);
} else if (key.name === "delete") {
if (cursor < comment.length) comment.splice(cursor, 1);
Expand Down
73 changes: 73 additions & 0 deletions test/edit-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test";
import { lineEnd, lineStart, resolveEditKey, wordEnd, wordStart } from "../src/edit-keys";

const chars = (text: string) => Array.from(text);

describe("word boundaries", () => {
test("wordStart skips trailing spaces then the word", () => {
const c = chars("foo bar ");
expect(wordStart(c, c.length)).toBe(4);
expect(wordStart(c, 4)).toBe(0);
expect(wordStart(c, 0)).toBe(0);
});

test("wordStart stops at a newline before crossing it", () => {
const c = chars("foo\nbar");
expect(wordStart(c, 4)).toBe(3);
expect(wordStart(c, 3)).toBe(0);
});

test("wordEnd skips leading spaces then the word", () => {
const c = chars(" foo bar");
expect(wordEnd(c, 0)).toBe(5);
expect(wordEnd(c, 5)).toBe(9);
expect(wordEnd(c, 9)).toBe(9);
});

test("wordEnd stops after a newline", () => {
const c = chars("foo\nbar");
expect(wordEnd(c, 3)).toBe(4);
});

test("CJK runs count as one word", () => {
const c = chars("한글 테스트");
expect(wordStart(c, c.length)).toBe(3);
expect(wordEnd(c, 0)).toBe(2);
});
});

describe("line boundaries", () => {
test("lineStart and lineEnd stay within the current line", () => {
const c = chars("ab\ncd\nef");
expect(lineStart(c, 4)).toBe(3);
expect(lineEnd(c, 4)).toBe(5);
expect(lineStart(c, 0)).toBe(0);
expect(lineEnd(c, 8)).toBe(8);
});
});

describe("resolveEditKey", () => {
test("readline kill bindings (Cmd/Opt+Backspace via Ghostty)", () => {
expect(resolveEditKey({ ctrl: true, name: "u", sequence: "\x15" })).toBe("delete-line");
expect(resolveEditKey({ ctrl: true, name: "w", sequence: "\x17" })).toBe("delete-word");
expect(resolveEditKey({ meta: true, name: "backspace", sequence: "\x1b\x7f" })).toBe("delete-word");
});

test("Option arrows move by word", () => {
expect(resolveEditKey({ meta: true, name: "left", sequence: "\x1b[1;3D" })).toBe("word-left");
expect(resolveEditKey({ meta: true, name: "right", sequence: "\x1b[1;3C" })).toBe("word-right");
expect(resolveEditKey({ meta: true, name: "b", sequence: "\x1bb" })).toBe("word-left");
expect(resolveEditKey({ meta: true, name: "f", sequence: "\x1bf" })).toBe("word-right");
});

test("Command arrows move to line edges", () => {
expect(resolveEditKey({ meta: true, name: "left", sequence: "\x1b[1;9D" })).toBe("line-start");
expect(resolveEditKey({ meta: true, name: "right", sequence: "\x1b[1;9C" })).toBe("line-end");
});

test("plain keys are left to the editor", () => {
expect(resolveEditKey({ name: "left", sequence: "\x1b[D" })).toBeNull();
expect(resolveEditKey({ name: "backspace", sequence: "\x7f" })).toBeNull();
expect(resolveEditKey({ name: "a", sequence: "a" })).toBeNull();
});
});