92
codex-cli/src/lib/approvals.test.ts
Normal file
92
codex-cli/src/lib/approvals.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { SafetyAssessment } from "./approvals";
|
||||
|
||||
import { canAutoApprove } from "./approvals";
|
||||
import { describe, test, expect } from "vitest";
|
||||
|
||||
describe("canAutoApprove()", () => {
|
||||
const env = {
|
||||
PATH: "/usr/local/bin:/usr/bin:/bin",
|
||||
HOME: "/home/user",
|
||||
};
|
||||
|
||||
const writeablePaths: Array<string> = [];
|
||||
const check = (command: ReadonlyArray<string>): SafetyAssessment =>
|
||||
canAutoApprove(command, "suggest", writeablePaths, env);
|
||||
|
||||
test("simple safe commands", () => {
|
||||
expect(check(["ls"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["cat", "file.txt"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "View file contents",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["pwd"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Print working directory",
|
||||
group: "Navigating",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("simple safe commands within a `bash -lc` call", () => {
|
||||
expect(check(["bash", "-lc", "ls"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["bash", "-lc", "ls $HOME"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["bash", "-lc", "git show ab9811cb90"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Git show",
|
||||
group: "Using git",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("bash -lc commands with unsafe redirects", () => {
|
||||
expect(check(["bash", "-lc", "echo hello > file.txt"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
// In theory, we could make our checker more sophisticated to auto-approve
|
||||
// This previously required approval, but now that we consider safe
|
||||
// operators like "&&" the entire expression can be auto‑approved.
|
||||
expect(check(["bash", "-lc", "ls && pwd"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("true command is considered safe", () => {
|
||||
expect(check(["true"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "No‑op (true)",
|
||||
group: "Utility",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("commands that should require approval", () => {
|
||||
// Should this be on the auto-approved list?
|
||||
expect(check(["printenv"])).toEqual({ type: "ask-user" });
|
||||
|
||||
expect(check(["git", "commit"])).toEqual({ type: "ask-user" });
|
||||
|
||||
expect(check(["pytest"])).toEqual({ type: "ask-user" });
|
||||
|
||||
expect(check(["cargo", "build"])).toEqual({ type: "ask-user" });
|
||||
});
|
||||
});
|
||||
542
codex-cli/src/lib/approvals.ts
Normal file
542
codex-cli/src/lib/approvals.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import type { ParseEntry, ControlOperator } from "shell-quote";
|
||||
|
||||
import {
|
||||
identify_files_added,
|
||||
identify_files_needed,
|
||||
} from "../utils/agent/apply-patch";
|
||||
import * as path from "path";
|
||||
import { parse } from "shell-quote";
|
||||
|
||||
export type SafetyAssessment = {
|
||||
/**
|
||||
* If set, this approval is for an apply_patch call and these are the
|
||||
* arguments.
|
||||
*/
|
||||
applyPatch?: ApplyPatchCommand;
|
||||
} & (
|
||||
| {
|
||||
type: "auto-approve";
|
||||
/**
|
||||
* This must be true if the command is not on the "known safe" list, but
|
||||
* was auto-approved due to `full-auto` mode.
|
||||
*/
|
||||
runInSandbox: boolean;
|
||||
reason: string;
|
||||
group: string;
|
||||
}
|
||||
| {
|
||||
type: "ask-user";
|
||||
}
|
||||
/**
|
||||
* Reserved for a case where we are certain the command is unsafe and should
|
||||
* not be presented as an option to the user.
|
||||
*/
|
||||
| {
|
||||
type: "reject";
|
||||
reason: string;
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: This should also contain the paths that will be affected.
|
||||
export type ApplyPatchCommand = {
|
||||
patch: string;
|
||||
};
|
||||
|
||||
export type ApprovalPolicy =
|
||||
/**
|
||||
* Under this policy, only "known safe" commands as defined by
|
||||
* `isSafeCommand()` that only read files will be auto-approved.
|
||||
*/
|
||||
| "suggest"
|
||||
|
||||
/**
|
||||
* In addition to commands that are auto-approved according to the rules for
|
||||
* "suggest", commands that write files within the user's approved list of
|
||||
* writable paths will also be auto-approved.
|
||||
*/
|
||||
| "auto-edit"
|
||||
|
||||
/**
|
||||
* All commands are auto-approved, but are expected to be run in a sandbox
|
||||
* where network access is disabled and writes are limited to a specific set
|
||||
* of paths.
|
||||
*/
|
||||
| "full-auto";
|
||||
|
||||
/**
|
||||
* Tries to assess whether a command is safe to run, though may defer to the
|
||||
* user for approval.
|
||||
*
|
||||
* Note `env` must be the same `env` that will be used to spawn the process.
|
||||
*/
|
||||
export function canAutoApprove(
|
||||
command: ReadonlyArray<string>,
|
||||
policy: ApprovalPolicy,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SafetyAssessment {
|
||||
try {
|
||||
if (command[0] === "apply_patch") {
|
||||
return command.length === 2 && typeof command[1] === "string"
|
||||
? canAutoApproveApplyPatch(command[1], writableRoots, policy)
|
||||
: {
|
||||
type: "reject",
|
||||
reason: "Invalid apply_patch command",
|
||||
};
|
||||
}
|
||||
|
||||
const isSafe = isSafeCommand(command);
|
||||
if (isSafe != null) {
|
||||
const { reason, group } = isSafe;
|
||||
return {
|
||||
type: "auto-approve",
|
||||
reason,
|
||||
group,
|
||||
runInSandbox: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
command[0] === "bash" &&
|
||||
command[1] === "-lc" &&
|
||||
typeof command[2] === "string" &&
|
||||
command.length === 3
|
||||
) {
|
||||
const applyPatchArg = tryParseApplyPatch(command[2]);
|
||||
if (applyPatchArg != null) {
|
||||
return canAutoApproveApplyPatch(applyPatchArg, writableRoots, policy);
|
||||
}
|
||||
|
||||
const bashCmd = parse(command[2], env);
|
||||
|
||||
// bashCmd could be a mix of strings and operators, e.g.:
|
||||
// "ls || (true && pwd)" => [ 'ls', { op: '||' }, '(', 'true', { op: '&&' }, 'pwd', ')' ]
|
||||
// We try to ensure that *every* command segment is deemed safe and that
|
||||
// all operators belong to an allow‑list. If so, the entire expression is
|
||||
// considered auto‑approvable.
|
||||
|
||||
const shellSafe = isEntireShellExpressionSafe(bashCmd);
|
||||
if (shellSafe != null) {
|
||||
const { reason, group } = shellSafe;
|
||||
return {
|
||||
type: "auto-approve",
|
||||
reason,
|
||||
group,
|
||||
runInSandbox: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return policy === "full-auto"
|
||||
? {
|
||||
type: "auto-approve",
|
||||
reason: "Full auto mode",
|
||||
group: "Running commands",
|
||||
runInSandbox: true,
|
||||
}
|
||||
: { type: "ask-user" };
|
||||
} catch (err) {
|
||||
if (policy === "full-auto") {
|
||||
return {
|
||||
type: "auto-approve",
|
||||
reason: "Full auto mode",
|
||||
group: "Running commands",
|
||||
runInSandbox: true,
|
||||
};
|
||||
} else {
|
||||
return { type: "ask-user" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canAutoApproveApplyPatch(
|
||||
applyPatchArg: string,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
policy: ApprovalPolicy,
|
||||
): SafetyAssessment {
|
||||
switch (policy) {
|
||||
case "full-auto":
|
||||
// Continue to see if this can be auto-approved.
|
||||
break;
|
||||
case "suggest":
|
||||
return {
|
||||
type: "ask-user",
|
||||
applyPatch: { patch: applyPatchArg },
|
||||
};
|
||||
case "auto-edit":
|
||||
// Continue to see if this can be auto-approved.
|
||||
break;
|
||||
}
|
||||
|
||||
if (isWritePatchConstrainedToWritablePaths(applyPatchArg, writableRoots)) {
|
||||
return {
|
||||
type: "auto-approve",
|
||||
reason: "apply_patch command is constrained to writable paths",
|
||||
group: "Editing",
|
||||
runInSandbox: false,
|
||||
applyPatch: { patch: applyPatchArg },
|
||||
};
|
||||
}
|
||||
|
||||
return policy === "full-auto"
|
||||
? {
|
||||
type: "auto-approve",
|
||||
reason: "Full auto mode",
|
||||
group: "Editing",
|
||||
runInSandbox: true,
|
||||
applyPatch: { patch: applyPatchArg },
|
||||
}
|
||||
: {
|
||||
type: "ask-user",
|
||||
applyPatch: { patch: applyPatchArg },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All items in `writablePaths` must be absolute paths.
|
||||
*/
|
||||
function isWritePatchConstrainedToWritablePaths(
|
||||
applyPatchArg: string,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
): boolean {
|
||||
// `identify_files_needed()` returns a list of files that will be modified or
|
||||
// deleted by the patch, so all of them should already exist on disk. These
|
||||
// candidate paths could be further canonicalized via fs.realpath(), though
|
||||
// that does seem necessary and may even cause false negatives (assuming we
|
||||
// allow writes in other directories that are symlinked from a writable path)
|
||||
//
|
||||
// By comparison, `identify_files_added()` returns a list of files that will
|
||||
// be added by the patch, so they should NOT exist on disk yet and therefore
|
||||
// using one with fs.realpath() should return an error.
|
||||
return (
|
||||
allPathsConstrainedTowritablePaths(
|
||||
identify_files_needed(applyPatchArg),
|
||||
writableRoots,
|
||||
) &&
|
||||
allPathsConstrainedTowritablePaths(
|
||||
identify_files_added(applyPatchArg),
|
||||
writableRoots,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function allPathsConstrainedTowritablePaths(
|
||||
candidatePaths: ReadonlyArray<string>,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
): boolean {
|
||||
return candidatePaths.every((candidatePath) =>
|
||||
isPathConstrainedTowritablePaths(candidatePath, writableRoots),
|
||||
);
|
||||
}
|
||||
|
||||
/** If candidatePath is relative, it will be resolved against cwd. */
|
||||
function isPathConstrainedTowritablePaths(
|
||||
candidatePath: string,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
): boolean {
|
||||
const candidateAbsolutePath = path.resolve(candidatePath);
|
||||
return writableRoots.some((writablePath) =>
|
||||
pathContains(writablePath, candidateAbsolutePath),
|
||||
);
|
||||
}
|
||||
|
||||
/** Both `parent` and `child` must be absolute paths. */
|
||||
function pathContains(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return (
|
||||
// relative path doesn't go outside parent
|
||||
!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `bashArg` might be something like "apply_patch << 'EOF' *** Begin...".
|
||||
* If this function returns a string, then it is the content the arg to
|
||||
* apply_patch with the heredoc removed.
|
||||
*/
|
||||
function tryParseApplyPatch(bashArg: string): string | null {
|
||||
const prefix = "apply_patch";
|
||||
if (!bashArg.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const heredoc = bashArg.slice(prefix.length);
|
||||
const heredocMatch = heredoc.match(
|
||||
/^\s*<<\s*['"]?(\w+)['"]?\n([\s\S]*?)\n\1/,
|
||||
);
|
||||
if (heredocMatch != null && typeof heredocMatch[2] === "string") {
|
||||
return heredocMatch[2].trim();
|
||||
} else {
|
||||
return heredoc.trim();
|
||||
}
|
||||
}
|
||||
|
||||
export type SafeCommandReason = {
|
||||
reason: string;
|
||||
group: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* If this is a "known safe" command, returns the (reason, group); otherwise,
|
||||
* returns null.
|
||||
*/
|
||||
export function isSafeCommand(
|
||||
command: ReadonlyArray<string>,
|
||||
): SafeCommandReason | null {
|
||||
const [cmd0, cmd1, cmd2, cmd3] = command;
|
||||
|
||||
switch (cmd0) {
|
||||
case "cd":
|
||||
return {
|
||||
reason: "Change directory",
|
||||
group: "Navigating",
|
||||
};
|
||||
case "ls":
|
||||
return {
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
};
|
||||
case "pwd":
|
||||
return {
|
||||
reason: "Print working directory",
|
||||
group: "Navigating",
|
||||
};
|
||||
case "true":
|
||||
return {
|
||||
reason: "No‑op (true)",
|
||||
group: "Utility",
|
||||
};
|
||||
case "echo":
|
||||
return { reason: "Echo string", group: "Printing" };
|
||||
case "cat":
|
||||
return {
|
||||
reason: "View file contents",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "rg":
|
||||
return {
|
||||
reason: "Ripgrep search",
|
||||
group: "Searching",
|
||||
};
|
||||
case "find":
|
||||
return {
|
||||
reason: "Find files or directories",
|
||||
group: "Searching",
|
||||
};
|
||||
case "grep":
|
||||
return {
|
||||
reason: "Text search (grep)",
|
||||
group: "Searching",
|
||||
};
|
||||
case "head":
|
||||
return {
|
||||
reason: "Show file head",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "tail":
|
||||
return {
|
||||
reason: "Show file tail",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "wc":
|
||||
return {
|
||||
reason: "Word count",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "which":
|
||||
return {
|
||||
reason: "Locate command",
|
||||
group: "Searching",
|
||||
};
|
||||
case "git":
|
||||
switch (cmd1) {
|
||||
case "status":
|
||||
return {
|
||||
reason: "Git status",
|
||||
group: "Versioning",
|
||||
};
|
||||
case "branch":
|
||||
return {
|
||||
reason: "List Git branches",
|
||||
group: "Versioning",
|
||||
};
|
||||
case "log":
|
||||
return {
|
||||
reason: "Git log",
|
||||
group: "Using git",
|
||||
};
|
||||
case "diff":
|
||||
return {
|
||||
reason: "Git diff",
|
||||
group: "Using git",
|
||||
};
|
||||
case "show":
|
||||
return {
|
||||
reason: "Git show",
|
||||
group: "Using git",
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
case "cargo":
|
||||
if (cmd1 === "check") {
|
||||
return {
|
||||
reason: "Cargo check",
|
||||
group: "Running command",
|
||||
};
|
||||
}
|
||||
break;
|
||||
case "sed":
|
||||
if (
|
||||
cmd1 === "-n" &&
|
||||
isValidSedNArg(cmd2) &&
|
||||
typeof cmd3 === "string" &&
|
||||
command.length === 4
|
||||
) {
|
||||
return {
|
||||
reason: "Sed print subset",
|
||||
group: "Reading files",
|
||||
};
|
||||
}
|
||||
break;
|
||||
case "oai":
|
||||
switch (cmd1) {
|
||||
case "show-lines":
|
||||
return {
|
||||
reason: "OAI show lines",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "find-files":
|
||||
return {
|
||||
reason: "OAI find files",
|
||||
group: "Searching",
|
||||
};
|
||||
case "file-outline":
|
||||
return {
|
||||
reason: "OAI file outline",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "rg":
|
||||
return {
|
||||
reason: "OAI ripgrep",
|
||||
group: "Searching",
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidSedNArg(arg: string | undefined): boolean {
|
||||
return arg != null && /^(\d+,)?\d+p$/.test(arg);
|
||||
}
|
||||
|
||||
// ---------------- Helper utilities for complex shell expressions -----------------
|
||||
|
||||
// A conservative allow‑list of bash operators that do not, on their own, cause
|
||||
// side effects. Redirections (>, >>, <, etc.) and command substitution `$()`
|
||||
// are intentionally excluded. Parentheses used for grouping are treated as
|
||||
// strings by `shell‑quote`, so we do not add them here. Reference:
|
||||
// https://github.com/substack/node-shell-quote#parsecmd-opts
|
||||
const SAFE_SHELL_OPERATORS: ReadonlySet<string> = new Set([
|
||||
"&&", // logical AND
|
||||
"||", // logical OR
|
||||
"|", // pipe
|
||||
";", // command separator
|
||||
]);
|
||||
|
||||
/**
|
||||
* Determines whether a parsed shell expression consists solely of safe
|
||||
* commands (as per `isSafeCommand`) combined using only operators in
|
||||
* `SAFE_SHELL_OPERATORS`.
|
||||
*
|
||||
* If entirely safe, returns the reason/group from the *first* command
|
||||
* segment so callers can surface a meaningful description. Otherwise returns
|
||||
* null.
|
||||
*/
|
||||
function isEntireShellExpressionSafe(
|
||||
parts: ReadonlyArray<ParseEntry>,
|
||||
): SafeCommandReason | null {
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Collect command segments delimited by operators. `shell‑quote` represents
|
||||
// subshell grouping parentheses as literal strings "(" and ")"; treat them
|
||||
// as unsafe to keep the logic simple (since subshells could introduce
|
||||
// unexpected scope changes).
|
||||
|
||||
let currentSegment: Array<string> = [];
|
||||
let firstReason: SafeCommandReason | null = null;
|
||||
|
||||
const flushSegment = (): boolean => {
|
||||
if (currentSegment.length === 0) {
|
||||
return true; // nothing to validate (possible leading operator)
|
||||
}
|
||||
const assessment = isSafeCommand(currentSegment);
|
||||
if (assessment == null) {
|
||||
return false;
|
||||
}
|
||||
if (firstReason == null) {
|
||||
firstReason = assessment;
|
||||
}
|
||||
currentSegment = [];
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === "string") {
|
||||
// If this string looks like an open/close parenthesis or brace, treat as
|
||||
// unsafe to avoid parsing complexity.
|
||||
if (part === "(" || part === ")" || part === "{" || part === "}") {
|
||||
return null;
|
||||
}
|
||||
currentSegment.push(part);
|
||||
} else if (isParseEntryWithOp(part)) {
|
||||
// Validate the segment accumulated so far.
|
||||
if (!flushSegment()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate the operator itself.
|
||||
if (!SAFE_SHELL_OPERATORS.has(part.op)) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Unknown token type
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate any trailing command segment.
|
||||
if (!flushSegment()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return firstReason;
|
||||
} catch (_err) {
|
||||
// If there's any kind of failure, just bail out and return null.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime type guard that narrows a `ParseEntry` to the variants that
|
||||
// carry an `op` field. Using a dedicated function avoids the need for
|
||||
// inline type assertions and makes the narrowing reusable and explicit.
|
||||
function isParseEntryWithOp(
|
||||
entry: ParseEntry,
|
||||
): entry is { op: ControlOperator } | { op: "glob"; pattern: string } {
|
||||
return (
|
||||
typeof entry === "object" &&
|
||||
entry != null &&
|
||||
// Using the safe `in` operator keeps the check property‑safe even when
|
||||
// `entry` is a `string`.
|
||||
"op" in entry &&
|
||||
typeof (entry as { op?: unknown }).op === "string"
|
||||
);
|
||||
}
|
||||
21
codex-cli/src/lib/format-command.test.ts
Normal file
21
codex-cli/src/lib/format-command.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { formatCommandForDisplay } from "./format-command";
|
||||
import { describe, test, expect } from "vitest";
|
||||
|
||||
describe("formatCommandForDisplay()", () => {
|
||||
test("ensure empty string arg appears in output", () => {
|
||||
expect(formatCommandForDisplay(["echo", ""])).toEqual("echo ''");
|
||||
});
|
||||
|
||||
test("ensure special characters are properly escaped", () => {
|
||||
expect(formatCommandForDisplay(["echo", "$HOME"])).toEqual("echo \\$HOME");
|
||||
});
|
||||
|
||||
test("ensure quotes are properly escaped", () => {
|
||||
expect(formatCommandForDisplay(["echo", "I can't believe this."])).toEqual(
|
||||
'echo "I can\'t believe this."',
|
||||
);
|
||||
expect(
|
||||
formatCommandForDisplay(["echo", 'So I said, "No ma\'am!"']),
|
||||
).toEqual('echo "So I said, \\"No ma\'am\\!\\""');
|
||||
});
|
||||
});
|
||||
53
codex-cli/src/lib/format-command.ts
Normal file
53
codex-cli/src/lib/format-command.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { quote } from "shell-quote";
|
||||
|
||||
/**
|
||||
* Format the args of an exec command for display as a single string. Prefer
|
||||
* this to doing `args.join(" ")` as this will handle quoting and escaping
|
||||
* correctly. See unit test for details.
|
||||
*/
|
||||
export function formatCommandForDisplay(command: Array<string>): string {
|
||||
// The model often wraps arbitrary shell commands in an invocation that looks
|
||||
// like:
|
||||
//
|
||||
// ["bash", "-lc", "'<actual command>'"]
|
||||
//
|
||||
// When displaying these back to the user, we do NOT want to show the
|
||||
// boiler‑plate "bash -lc" wrapper. Instead, we want to surface only the
|
||||
// actual command that bash will evaluate.
|
||||
|
||||
// Historically we detected this by first quoting the entire command array
|
||||
// with `shell‑quote` and then using a regular expression to peel off the
|
||||
// `bash -lc '…'` prefix. However, that approach was brittle (it depended on
|
||||
// the exact quoting behavior of `shell-quote`) and unnecessarily
|
||||
// inefficient.
|
||||
|
||||
// A simpler and more robust approach is to look at the raw command array
|
||||
// itself. If it matches the shape produced by our exec helpers—exactly three
|
||||
// entries where the first two are «bash» and «-lc»—then we can return the
|
||||
// third entry directly (after stripping surrounding single quotes if they
|
||||
// are present).
|
||||
|
||||
try {
|
||||
if (
|
||||
command.length === 3 &&
|
||||
command[0] === "bash" &&
|
||||
command[1] === "-lc" &&
|
||||
typeof command[2] === "string"
|
||||
) {
|
||||
let inner = command[2];
|
||||
|
||||
// Some callers wrap the actual command in single quotes (e.g. `'echo foo'`).
|
||||
// For display purposes we want to drop those outer quotes so that the
|
||||
// rendered command looks exactly like what the user typed.
|
||||
if (inner.startsWith("'") && inner.endsWith("'")) {
|
||||
inner = inner.slice(1, -1);
|
||||
}
|
||||
|
||||
return inner;
|
||||
}
|
||||
|
||||
return quote(command);
|
||||
} catch (err) {
|
||||
return command.join(" ");
|
||||
}
|
||||
}
|
||||
45
codex-cli/src/lib/parse-apply-patch.test.ts
Normal file
45
codex-cli/src/lib/parse-apply-patch.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { parseApplyPatch } from "./parse-apply-patch";
|
||||
import { expect, test, describe } from "vitest";
|
||||
|
||||
// Helper function to unwrap a non‑null result in tests that expect success.
|
||||
function mustParse(patch: string) {
|
||||
const parsed = parseApplyPatch(patch);
|
||||
if (parsed == null) {
|
||||
throw new Error(
|
||||
"Expected patch to be valid, but parseApplyPatch returned null",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
describe("parseApplyPatch", () => {
|
||||
test("parses create, update and delete operations in a single patch", () => {
|
||||
const patch = `*** Begin Patch\n*** Add File: created.txt\n+hello\n+world\n*** Update File: updated.txt\n@@\n-old\n+new\n*** Delete File: removed.txt\n*** End Patch`;
|
||||
|
||||
const ops = mustParse(patch);
|
||||
|
||||
expect(ops).toEqual([
|
||||
{
|
||||
type: "create",
|
||||
path: "created.txt",
|
||||
content: "hello\nworld",
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
path: "updated.txt",
|
||||
update: "@@\n-old\n+new",
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
},
|
||||
{
|
||||
type: "delete",
|
||||
path: "removed.txt",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns null for an invalid patch (missing prefix)", () => {
|
||||
const invalid = `*** Add File: foo.txt\n+bar\n*** End Patch`;
|
||||
expect(parseApplyPatch(invalid)).toBeNull();
|
||||
});
|
||||
});
|
||||
112
codex-cli/src/lib/parse-apply-patch.ts
Normal file
112
codex-cli/src/lib/parse-apply-patch.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export type ApplyPatchCreateFileOp = {
|
||||
type: "create";
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ApplyPatchDeleteFileOp = {
|
||||
type: "delete";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ApplyPatchUpdateFileOp = {
|
||||
type: "update";
|
||||
path: string;
|
||||
update: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
};
|
||||
|
||||
export type ApplyPatchOp =
|
||||
| ApplyPatchCreateFileOp
|
||||
| ApplyPatchDeleteFileOp
|
||||
| ApplyPatchUpdateFileOp;
|
||||
|
||||
const PATCH_PREFIX = "*** Begin Patch\n";
|
||||
const PATCH_SUFFIX = "\n*** End Patch";
|
||||
const ADD_FILE_PREFIX = "*** Add File: ";
|
||||
const DELETE_FILE_PREFIX = "*** Delete File: ";
|
||||
const UPDATE_FILE_PREFIX = "*** Update File: ";
|
||||
const END_OF_FILE_PREFIX = "*** End of File";
|
||||
const HUNK_ADD_LINE_PREFIX = "+";
|
||||
|
||||
/**
|
||||
* @returns null when the patch is invalid
|
||||
*/
|
||||
export function parseApplyPatch(patch: string): Array<ApplyPatchOp> | null {
|
||||
if (!patch.startsWith(PATCH_PREFIX)) {
|
||||
// Patch must begin with '*** Begin Patch'
|
||||
return null;
|
||||
} else if (!patch.endsWith(PATCH_SUFFIX)) {
|
||||
// Patch must end with '*** End Patch'
|
||||
return null;
|
||||
}
|
||||
|
||||
const patchBody = patch.slice(
|
||||
PATCH_PREFIX.length,
|
||||
patch.length - PATCH_SUFFIX.length,
|
||||
);
|
||||
|
||||
const lines = patchBody.split("\n");
|
||||
|
||||
const ops: Array<ApplyPatchOp> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(END_OF_FILE_PREFIX)) {
|
||||
continue;
|
||||
} else if (line.startsWith(ADD_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "create",
|
||||
path: line.slice(ADD_FILE_PREFIX.length).trim(),
|
||||
content: "",
|
||||
});
|
||||
continue;
|
||||
} else if (line.startsWith(DELETE_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "delete",
|
||||
path: line.slice(DELETE_FILE_PREFIX.length).trim(),
|
||||
});
|
||||
continue;
|
||||
} else if (line.startsWith(UPDATE_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "update",
|
||||
path: line.slice(UPDATE_FILE_PREFIX.length).trim(),
|
||||
update: "",
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastOp = ops[ops.length - 1];
|
||||
|
||||
if (lastOp?.type === "create") {
|
||||
lastOp.content = appendLine(
|
||||
lastOp.content,
|
||||
line.slice(HUNK_ADD_LINE_PREFIX.length),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastOp?.type !== "update") {
|
||||
// Expected update op but got ${lastOp?.type} for line ${line}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.startsWith(HUNK_ADD_LINE_PREFIX)) {
|
||||
lastOp.added += 1;
|
||||
} else if (line.startsWith("-")) {
|
||||
lastOp.deleted += 1;
|
||||
}
|
||||
lastOp.update += lastOp.update ? "\n" + line : line;
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
function appendLine(content: string, line: string) {
|
||||
if (!content.length) {
|
||||
return line;
|
||||
}
|
||||
return [content, line].join("\n");
|
||||
}
|
||||
852
codex-cli/src/lib/text-buffer.ts
Normal file
852
codex-cli/src/lib/text-buffer.ts
Normal file
@@ -0,0 +1,852 @@
|
||||
/* eslint‑disable no-bitwise */
|
||||
export type Direction =
|
||||
| "left"
|
||||
| "right"
|
||||
| "up"
|
||||
| "down"
|
||||
| "wordLeft"
|
||||
| "wordRight"
|
||||
| "home"
|
||||
| "end";
|
||||
|
||||
// Simple helper for word‑wise ops.
|
||||
function isWordChar(ch: string | undefined): boolean {
|
||||
if (ch === undefined) {
|
||||
return false;
|
||||
}
|
||||
return !/[\s,.;!?]/.test(ch);
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return v < min ? min : v > max ? max : v;
|
||||
}
|
||||
|
||||
/*
|
||||
* -------------------------------------------------------------------------
|
||||
* Unicode‑aware helpers (work at the code‑point level rather than UTF‑16
|
||||
* code units so that surrogate‑pair emoji count as one "column".)
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
function toCodePoints(str: string): Array<string> {
|
||||
// [...str] or Array.from both iterate by UTF‑32 code point, handling
|
||||
// surrogate pairs correctly.
|
||||
return Array.from(str);
|
||||
}
|
||||
|
||||
function cpLen(str: string): number {
|
||||
return toCodePoints(str).length;
|
||||
}
|
||||
|
||||
function cpSlice(str: string, start: number, end?: number): string {
|
||||
// Slice by code‑point indices and re‑join.
|
||||
const arr = toCodePoints(str).slice(start, end);
|
||||
return arr.join("");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
// Enable verbose logging only when requested via env var.
|
||||
const DEBUG =
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true";
|
||||
|
||||
function dbg(...args: Array<unknown>): void {
|
||||
if (DEBUG) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[TextBuffer]", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export default class TextBuffer {
|
||||
private lines: Array<string>;
|
||||
private cursorRow = 0;
|
||||
private cursorCol = 0;
|
||||
private scrollRow = 0;
|
||||
private scrollCol = 0;
|
||||
|
||||
/**
|
||||
* When the user moves the caret vertically we try to keep their original
|
||||
* horizontal column even when passing through shorter lines. We remember
|
||||
* that *preferred* column in this field while the user is still travelling
|
||||
* vertically. Any explicit horizontal movement resets the preference.
|
||||
*/
|
||||
private preferredCol: number | null = null;
|
||||
|
||||
/* a single integer that bumps every time text changes */
|
||||
private version = 0;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* History & clipboard
|
||||
* ---------------------------------------------------------------- */
|
||||
private undoStack: Array<{ lines: Array<string>; row: number; col: number }> =
|
||||
[];
|
||||
private redoStack: Array<{ lines: Array<string>; row: number; col: number }> =
|
||||
[];
|
||||
private historyLimit = 100;
|
||||
|
||||
private clipboard: string | null = null;
|
||||
|
||||
constructor(text = "") {
|
||||
this.lines = text.split("\n");
|
||||
if (this.lines.length === 0) {
|
||||
this.lines = [""];
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* External editor integration (git‑style $EDITOR workflow)
|
||||
* =================================================================== */
|
||||
|
||||
/**
|
||||
* Opens the current buffer contents in the user’s preferred terminal text
|
||||
* editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
|
||||
* until the editor exits, then reloads the file and replaces the in‑memory
|
||||
* buffer with whatever the user saved.
|
||||
*
|
||||
* The operation is treated as a single undoable edit – we snapshot the
|
||||
* previous state *once* before launching the editor so one `undo()` will
|
||||
* revert the entire change set.
|
||||
*
|
||||
* Note: We purposefully rely on the *synchronous* spawn API so that the
|
||||
* calling process genuinely waits for the editor to close before
|
||||
* continuing. This mirrors Git’s behaviour and simplifies downstream
|
||||
* control‑flow (callers can simply `await` the Promise).
|
||||
*/
|
||||
async openInExternalEditor(opts: { editor?: string } = {}): Promise<void> {
|
||||
// Deliberately use `require()` so that unit tests can stub the
|
||||
// respective modules with `vi.spyOn(require("node:child_process"), …)`.
|
||||
// Dynamic `import()` would circumvent those CommonJS stubs.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pathMod = require("node:path");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require("node:fs");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const os = require("node:os");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const editor =
|
||||
opts.editor ??
|
||||
process.env["VISUAL"] ??
|
||||
process.env["EDITOR"] ??
|
||||
(process.platform === "win32" ? "notepad" : "vi");
|
||||
|
||||
// Prepare a temporary file with the current contents. We use mkdtempSync
|
||||
// to obtain an isolated directory and avoid name collisions.
|
||||
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), "codex-edit-"));
|
||||
const filePath = pathMod.join(tmpDir, "buffer.txt");
|
||||
|
||||
fs.writeFileSync(filePath, this.getText(), "utf8");
|
||||
|
||||
// One snapshot for undo semantics *before* we mutate anything.
|
||||
this.pushUndo();
|
||||
|
||||
// The child inherits stdio so the user can interact with the editor as if
|
||||
// they had launched it directly.
|
||||
const { status, error } = spawnSync(editor, [filePath], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (typeof status === "number" && status !== 0) {
|
||||
throw new Error(`External editor exited with status ${status}`);
|
||||
}
|
||||
|
||||
// Read the edited contents back in – normalise line endings to \n.
|
||||
let newText = fs.readFileSync(filePath, "utf8");
|
||||
newText = newText.replace(/\r\n?/g, "\n");
|
||||
|
||||
// Update buffer.
|
||||
this.lines = newText.split("\n");
|
||||
if (this.lines.length === 0) {
|
||||
this.lines = [""];
|
||||
}
|
||||
|
||||
// Position the caret at EOF.
|
||||
this.cursorRow = this.lines.length - 1;
|
||||
this.cursorCol = cpLen(this.line(this.cursorRow));
|
||||
|
||||
// Reset scroll offsets so the new end is visible.
|
||||
this.scrollRow = Math.max(0, this.cursorRow - 1);
|
||||
this.scrollCol = 0;
|
||||
|
||||
this.version++;
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
* Geometry helpers
|
||||
* ===================================================================== */
|
||||
private line(r: number): string {
|
||||
return this.lines[r] ?? "";
|
||||
}
|
||||
private lineLen(r: number): number {
|
||||
return cpLen(this.line(r));
|
||||
}
|
||||
|
||||
private ensureCursorInRange(): void {
|
||||
this.cursorRow = clamp(this.cursorRow, 0, this.lines.length - 1);
|
||||
this.cursorCol = clamp(this.cursorCol, 0, this.lineLen(this.cursorRow));
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* History helpers
|
||||
* =================================================================== */
|
||||
private snapshot() {
|
||||
return {
|
||||
lines: this.lines.slice(),
|
||||
row: this.cursorRow,
|
||||
col: this.cursorCol,
|
||||
};
|
||||
}
|
||||
|
||||
private pushUndo() {
|
||||
dbg("pushUndo", { cursor: this.getCursor(), text: this.getText() });
|
||||
this.undoStack.push(this.snapshot());
|
||||
if (this.undoStack.length > this.historyLimit) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
// once we mutate we clear redo
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a snapshot and return true if restoration happened.
|
||||
*/
|
||||
private restore(
|
||||
state: { lines: Array<string>; row: number; col: number } | undefined,
|
||||
): boolean {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
this.lines = state.lines.slice();
|
||||
this.cursorRow = state.row;
|
||||
this.cursorCol = state.col;
|
||||
this.ensureCursorInRange();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
* Scrolling helpers
|
||||
* ===================================================================== */
|
||||
private ensureCursorVisible(vp: Viewport) {
|
||||
const { height, width } = vp;
|
||||
|
||||
if (this.cursorRow < this.scrollRow) {
|
||||
this.scrollRow = this.cursorRow;
|
||||
} else if (this.cursorRow >= this.scrollRow + height) {
|
||||
this.scrollRow = this.cursorRow - height + 1;
|
||||
}
|
||||
|
||||
if (this.cursorCol < this.scrollCol) {
|
||||
this.scrollCol = this.cursorCol;
|
||||
} else if (this.cursorCol >= this.scrollCol + width) {
|
||||
this.scrollCol = this.cursorCol - width + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
* Public read‑only accessors
|
||||
* ===================================================================== */
|
||||
getVersion(): number {
|
||||
return this.version;
|
||||
}
|
||||
getCursor(): [number, number] {
|
||||
return [this.cursorRow, this.cursorCol];
|
||||
}
|
||||
getVisibleLines(vp: Viewport): Array<string> {
|
||||
// Whenever the viewport dimensions change (e.g. on a terminal resize) we
|
||||
// need to re‑evaluate whether the current scroll offset still keeps the
|
||||
// caret visible. Calling `ensureCursorVisible` here guarantees that mere
|
||||
// re‑renders – even when not triggered by user input – will adjust the
|
||||
// horizontal and vertical scroll positions so the cursor remains in view.
|
||||
this.ensureCursorVisible(vp);
|
||||
|
||||
return this.lines.slice(this.scrollRow, this.scrollRow + vp.height);
|
||||
}
|
||||
getText(): string {
|
||||
return this.lines.join("\n");
|
||||
}
|
||||
getLines(): Array<string> {
|
||||
return this.lines.slice();
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* History public API – undo / redo
|
||||
* =================================================================== */
|
||||
undo(): boolean {
|
||||
const state = this.undoStack.pop();
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
// push current to redo before restore
|
||||
this.redoStack.push(this.snapshot());
|
||||
this.restore(state);
|
||||
this.version++;
|
||||
return true;
|
||||
}
|
||||
|
||||
redo(): boolean {
|
||||
const state = this.redoStack.pop();
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
// push current to undo before restore
|
||||
this.undoStack.push(this.snapshot());
|
||||
this.restore(state);
|
||||
this.version++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
* Editing operations
|
||||
* ===================================================================== */
|
||||
/**
|
||||
* Insert a single character or string without newlines. If the string
|
||||
* contains a newline we delegate to insertStr so that line splitting
|
||||
* logic is shared.
|
||||
*/
|
||||
insert(ch: string): void {
|
||||
// Handle pasted blocks that may contain newline sequences (\n, \r or
|
||||
// Windows‑style \r\n). Delegate to `insertStr` so the splitting logic is
|
||||
// centralised.
|
||||
if (/[\n\r]/.test(ch)) {
|
||||
this.insertStr(ch);
|
||||
return;
|
||||
}
|
||||
|
||||
dbg("insert", { ch, beforeCursor: this.getCursor() });
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
const line = this.line(this.cursorRow);
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, this.cursorCol) + ch + cpSlice(line, this.cursorCol);
|
||||
this.cursorCol += ch.length;
|
||||
this.version++;
|
||||
|
||||
dbg("insert:after", {
|
||||
cursor: this.getCursor(),
|
||||
line: this.line(this.cursorRow),
|
||||
});
|
||||
}
|
||||
|
||||
newline(): void {
|
||||
dbg("newline", { beforeCursor: this.getCursor() });
|
||||
this.pushUndo();
|
||||
|
||||
const l = this.line(this.cursorRow);
|
||||
const before = cpSlice(l, 0, this.cursorCol);
|
||||
const after = cpSlice(l, this.cursorCol);
|
||||
|
||||
this.lines[this.cursorRow] = before;
|
||||
this.lines.splice(this.cursorRow + 1, 0, after);
|
||||
|
||||
this.cursorRow += 1;
|
||||
this.cursorCol = 0;
|
||||
this.version++;
|
||||
|
||||
dbg("newline:after", {
|
||||
cursor: this.getCursor(),
|
||||
lines: [this.line(this.cursorRow - 1), this.line(this.cursorRow)],
|
||||
});
|
||||
}
|
||||
|
||||
backspace(): void {
|
||||
dbg("backspace", { beforeCursor: this.getCursor() });
|
||||
if (this.cursorCol === 0 && this.cursorRow === 0) {
|
||||
return;
|
||||
} // nothing to delete
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
if (this.cursorCol > 0) {
|
||||
const line = this.line(this.cursorRow);
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, this.cursorCol - 1) + cpSlice(line, this.cursorCol);
|
||||
this.cursorCol--;
|
||||
} else if (this.cursorRow > 0) {
|
||||
// merge with previous
|
||||
const prev = this.line(this.cursorRow - 1);
|
||||
const cur = this.line(this.cursorRow);
|
||||
const newCol = cpLen(prev);
|
||||
this.lines[this.cursorRow - 1] = prev + cur;
|
||||
this.lines.splice(this.cursorRow, 1);
|
||||
this.cursorRow--;
|
||||
this.cursorCol = newCol;
|
||||
}
|
||||
this.version++;
|
||||
|
||||
dbg("backspace:after", {
|
||||
cursor: this.getCursor(),
|
||||
line: this.line(this.cursorRow),
|
||||
});
|
||||
}
|
||||
|
||||
del(): void {
|
||||
dbg("delete", { beforeCursor: this.getCursor() });
|
||||
const line = this.line(this.cursorRow);
|
||||
if (this.cursorCol < this.lineLen(this.cursorRow)) {
|
||||
this.pushUndo();
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, this.cursorCol) + cpSlice(line, this.cursorCol + 1);
|
||||
} else if (this.cursorRow < this.lines.length - 1) {
|
||||
this.pushUndo();
|
||||
const next = this.line(this.cursorRow + 1);
|
||||
this.lines[this.cursorRow] = line + next;
|
||||
this.lines.splice(this.cursorRow + 1, 1);
|
||||
}
|
||||
this.version++;
|
||||
|
||||
dbg("delete:after", {
|
||||
cursor: this.getCursor(),
|
||||
line: this.line(this.cursorRow),
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Word‑wise deletion helpers – exposed publicly so tests (and future
|
||||
* key‑bindings) can invoke them directly.
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/** Delete the word to the *left* of the caret, mirroring common
|
||||
* Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent
|
||||
* whitespace *and* the word characters immediately preceding the caret are
|
||||
* removed. If the caret is already at column‑0 this becomes a no‑op. */
|
||||
deleteWordLeft(): void {
|
||||
dbg("deleteWordLeft", { beforeCursor: this.getCursor() });
|
||||
|
||||
if (this.cursorCol === 0 && this.cursorRow === 0) {
|
||||
return;
|
||||
} // Nothing to delete
|
||||
|
||||
// When at column‑0 but *not* on the first row we merge with the previous
|
||||
// line – matching the behaviour of `backspace` for uniform UX.
|
||||
if (this.cursorCol === 0) {
|
||||
this.backspace();
|
||||
return;
|
||||
}
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
const line = this.line(this.cursorRow);
|
||||
const arr = toCodePoints(line);
|
||||
|
||||
// Step 1 – skip over any separators sitting *immediately* to the left of
|
||||
// the caret so that consecutive deletions wipe runs of whitespace first
|
||||
// then words.
|
||||
let start = this.cursorCol;
|
||||
while (start > 0 && !isWordChar(arr[start - 1])) {
|
||||
start--;
|
||||
}
|
||||
|
||||
// Step 2 – now skip the word characters themselves.
|
||||
while (start > 0 && isWordChar(arr[start - 1])) {
|
||||
start--;
|
||||
}
|
||||
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, start) + cpSlice(line, this.cursorCol);
|
||||
this.cursorCol = start;
|
||||
this.version++;
|
||||
|
||||
dbg("deleteWordLeft:after", {
|
||||
cursor: this.getCursor(),
|
||||
line: this.line(this.cursorRow),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete the word to the *right* of the caret, akin to many editors'
|
||||
* Ctrl/Alt+Delete shortcut. Removes any whitespace/punctuation that
|
||||
* follows the caret and the next contiguous run of word characters. */
|
||||
deleteWordRight(): void {
|
||||
dbg("deleteWordRight", { beforeCursor: this.getCursor() });
|
||||
|
||||
const line = this.line(this.cursorRow);
|
||||
const arr = toCodePoints(line);
|
||||
if (
|
||||
this.cursorCol >= arr.length &&
|
||||
this.cursorRow === this.lines.length - 1
|
||||
) {
|
||||
return;
|
||||
} // nothing to delete
|
||||
|
||||
// At end‑of‑line ➜ merge with next row (mirrors `del` behaviour).
|
||||
if (this.cursorCol >= arr.length) {
|
||||
this.del();
|
||||
return;
|
||||
}
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
let end = this.cursorCol;
|
||||
|
||||
// Skip separators *first* so that consecutive calls gradually chew
|
||||
// through whitespace then whole words.
|
||||
while (end < arr.length && !isWordChar(arr[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
// Skip the word characters.
|
||||
while (end < arr.length && isWordChar(arr[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, this.cursorCol) + cpSlice(line, end);
|
||||
// caret stays in place
|
||||
this.version++;
|
||||
|
||||
dbg("deleteWordRight:after", {
|
||||
cursor: this.getCursor(),
|
||||
line: this.line(this.cursorRow),
|
||||
});
|
||||
}
|
||||
|
||||
move(dir: Direction): void {
|
||||
const before = this.getCursor();
|
||||
switch (dir) {
|
||||
case "left":
|
||||
this.preferredCol = null;
|
||||
if (this.cursorCol > 0) {
|
||||
this.cursorCol--;
|
||||
} else if (this.cursorRow > 0) {
|
||||
this.cursorRow--;
|
||||
this.cursorCol = this.lineLen(this.cursorRow);
|
||||
}
|
||||
break;
|
||||
case "right":
|
||||
this.preferredCol = null;
|
||||
if (this.cursorCol < this.lineLen(this.cursorRow)) {
|
||||
this.cursorCol++;
|
||||
} else if (this.cursorRow < this.lines.length - 1) {
|
||||
this.cursorRow++;
|
||||
this.cursorCol = 0;
|
||||
}
|
||||
break;
|
||||
case "up":
|
||||
if (this.cursorRow > 0) {
|
||||
if (this.preferredCol == null) {
|
||||
this.preferredCol = this.cursorCol;
|
||||
}
|
||||
this.cursorRow--;
|
||||
this.cursorCol = clamp(
|
||||
this.preferredCol,
|
||||
0,
|
||||
this.lineLen(this.cursorRow),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "down":
|
||||
if (this.cursorRow < this.lines.length - 1) {
|
||||
if (this.preferredCol == null) {
|
||||
this.preferredCol = this.cursorCol;
|
||||
}
|
||||
this.cursorRow++;
|
||||
this.cursorCol = clamp(
|
||||
this.preferredCol,
|
||||
0,
|
||||
this.lineLen(this.cursorRow),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "home":
|
||||
this.preferredCol = null;
|
||||
this.cursorCol = 0;
|
||||
break;
|
||||
case "end":
|
||||
this.preferredCol = null;
|
||||
this.cursorCol = this.lineLen(this.cursorRow);
|
||||
break;
|
||||
case "wordLeft": {
|
||||
this.preferredCol = null;
|
||||
const regex = /[\s,.;!?]+/g;
|
||||
const slice = cpSlice(
|
||||
this.line(this.cursorRow),
|
||||
0,
|
||||
this.cursorCol,
|
||||
).replace(/[\s,.;!?]+$/, "");
|
||||
let lastIdx = 0;
|
||||
let m;
|
||||
while ((m = regex.exec(slice)) != null) {
|
||||
lastIdx = m.index;
|
||||
}
|
||||
const last = cpLen(slice.slice(0, lastIdx));
|
||||
this.cursorCol = last === 0 ? 0 : last + 1;
|
||||
break;
|
||||
}
|
||||
case "wordRight": {
|
||||
this.preferredCol = null;
|
||||
const regex = /[\s,.;!?]+/g;
|
||||
const l = this.line(this.cursorRow);
|
||||
let moved = false;
|
||||
let m;
|
||||
while ((m = regex.exec(l)) != null) {
|
||||
const cpIdx = cpLen(l.slice(0, m.index));
|
||||
if (cpIdx > this.cursorCol) {
|
||||
// We want to land *at the beginning* of the separator run so that a
|
||||
// subsequent move("right") behaves naturally.
|
||||
this.cursorCol = cpIdx;
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!moved) {
|
||||
// No boundary to the right – jump to EOL.
|
||||
this.cursorCol = this.lineLen(this.cursorRow);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
dbg("move", { dir, before, after: this.getCursor() });
|
||||
}
|
||||
|
||||
/*
|
||||
* If the user performed any movement other than a consecutive vertical
|
||||
* traversal we clear the preferred column so the next vertical run starts
|
||||
* afresh. The cases that keep the preference already returned earlier.
|
||||
*/
|
||||
if (dir !== "up" && dir !== "down") {
|
||||
this.preferredCol = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* Higher‑level helpers
|
||||
* =================================================================== */
|
||||
|
||||
/**
|
||||
* Insert an arbitrary string, possibly containing internal newlines.
|
||||
* Returns true if the buffer was modified.
|
||||
*/
|
||||
insertStr(str: string): boolean {
|
||||
dbg("insertStr", { str, beforeCursor: this.getCursor() });
|
||||
if (str === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalise all newline conventions (\r, \n, \r\n) to a single '\n'.
|
||||
const normalised = str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
// Fast path: resulted in single‑line string ➜ delegate back to insert
|
||||
if (!normalised.includes("\n")) {
|
||||
this.insert(normalised);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
const parts = normalised.split("\n");
|
||||
const before = cpSlice(this.line(this.cursorRow), 0, this.cursorCol);
|
||||
const after = cpSlice(this.line(this.cursorRow), this.cursorCol);
|
||||
|
||||
// Replace current line with first part combined with before text
|
||||
this.lines[this.cursorRow] = before + parts[0];
|
||||
|
||||
// Middle lines (if any) are inserted verbatim after current row
|
||||
if (parts.length > 2) {
|
||||
const middle = parts.slice(1, -1);
|
||||
this.lines.splice(this.cursorRow + 1, 0, ...middle);
|
||||
}
|
||||
|
||||
// Smart handling of the *final* inserted part:
|
||||
// • When the caret is mid‑line we preserve existing behaviour – merge
|
||||
// the last part with the text to the **right** of the caret so that
|
||||
// inserting in the middle of a line keeps the remainder on the same
|
||||
// row (e.g. "he|llo" → paste "x\ny" ⇒ "he x", "y llo").
|
||||
// • When the caret is at column‑0 we instead treat the current line as
|
||||
// a *separate* row that follows the inserted block. This mirrors
|
||||
// common editor behaviour and avoids the unintuitive merge that led
|
||||
// to "cd"+"ef" → "cdef" in the failing tests.
|
||||
|
||||
// Append the last part combined with original after text as a new line
|
||||
const last = parts[parts.length - 1] + after;
|
||||
this.lines.splice(this.cursorRow + (parts.length - 1), 0, last);
|
||||
|
||||
// Update cursor position to end of last inserted part (before 'after')
|
||||
this.cursorRow += parts.length - 1;
|
||||
// `parts` is guaranteed to have at least one element here because
|
||||
// `split("\n")` always returns an array with ≥1 entry. Tell the
|
||||
// compiler so we can pass a plain `string` to `cpLen`.
|
||||
this.cursorCol = cpLen(parts[parts.length - 1]!);
|
||||
|
||||
this.version++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* Selection & clipboard helpers (minimal)
|
||||
* =================================================================== */
|
||||
|
||||
private selectionAnchor: [number, number] | null = null;
|
||||
|
||||
startSelection(): void {
|
||||
this.selectionAnchor = [this.cursorRow, this.cursorCol];
|
||||
}
|
||||
|
||||
endSelection(): void {
|
||||
// no‑op for now, kept for API symmetry
|
||||
// we rely on anchor + current cursor to compute selection
|
||||
}
|
||||
|
||||
/** Extract selected text. Returns null if no valid selection. */
|
||||
private getSelectedText(): string | null {
|
||||
if (!this.selectionAnchor) {
|
||||
return null;
|
||||
}
|
||||
const [ar, ac] = this.selectionAnchor;
|
||||
const [br, bc] = [this.cursorRow, this.cursorCol];
|
||||
|
||||
// Determine ordering
|
||||
if (ar === br && ac === bc) {
|
||||
return null;
|
||||
} // empty selection
|
||||
|
||||
const topBefore = ar < br || (ar === br && ac < bc);
|
||||
const [sr, sc, er, ec] = topBefore ? [ar, ac, br, bc] : [br, bc, ar, ac];
|
||||
|
||||
if (sr === er) {
|
||||
return cpSlice(this.line(sr), sc, ec);
|
||||
}
|
||||
|
||||
const parts: Array<string> = [];
|
||||
parts.push(cpSlice(this.line(sr), sc));
|
||||
for (let r = sr + 1; r < er; r++) {
|
||||
parts.push(this.line(r));
|
||||
}
|
||||
parts.push(cpSlice(this.line(er), 0, ec));
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
copy(): string | null {
|
||||
const txt = this.getSelectedText();
|
||||
if (txt == null) {
|
||||
return null;
|
||||
}
|
||||
this.clipboard = txt;
|
||||
return txt;
|
||||
}
|
||||
|
||||
paste(): boolean {
|
||||
if (this.clipboard == null) {
|
||||
return false;
|
||||
}
|
||||
return this.insertStr(this.clipboard);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
* High level "handleInput" – receives what Ink gives us
|
||||
* Returns true when buffer mutated (=> re‑render)
|
||||
* ===================================================================== */
|
||||
handleInput(
|
||||
input: string | undefined,
|
||||
key: Record<string, boolean>,
|
||||
vp: Viewport,
|
||||
): boolean {
|
||||
if (DEBUG) {
|
||||
dbg("handleInput", { input, key, cursor: this.getCursor() });
|
||||
}
|
||||
const beforeVer = this.version;
|
||||
const [beforeRow, beforeCol] = this.getCursor();
|
||||
|
||||
if (key["escape"]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* new line — Ink sets either `key.return` *or* passes a literal "\n" */
|
||||
if (key["return"] || input === "\r" || input === "\n") {
|
||||
this.newline();
|
||||
} else if (
|
||||
key["leftArrow"] &&
|
||||
!key["meta"] &&
|
||||
!key["ctrl"] &&
|
||||
!key["alt"]
|
||||
) {
|
||||
/* navigation */
|
||||
this.move("left");
|
||||
} else if (
|
||||
key["rightArrow"] &&
|
||||
!key["meta"] &&
|
||||
!key["ctrl"] &&
|
||||
!key["alt"]
|
||||
) {
|
||||
this.move("right");
|
||||
} else if (key["upArrow"]) {
|
||||
this.move("up");
|
||||
} else if (key["downArrow"]) {
|
||||
this.move("down");
|
||||
} else if ((key["meta"] || key["ctrl"] || key["alt"]) && key["leftArrow"]) {
|
||||
this.move("wordLeft");
|
||||
} else if (
|
||||
(key["meta"] || key["ctrl"] || key["alt"]) &&
|
||||
key["rightArrow"]
|
||||
) {
|
||||
this.move("wordRight");
|
||||
} else if (key["home"]) {
|
||||
this.move("home");
|
||||
} else if (key["end"]) {
|
||||
this.move("end");
|
||||
}
|
||||
/* delete */
|
||||
// In raw terminal mode many frameworks (Ink included) surface a physical
|
||||
// Backspace key‑press as the single DEL (0x7f) byte placed in `input` with
|
||||
// no `key.backspace` flag set. Treat that byte exactly like an ordinary
|
||||
// Backspace for parity with textarea.rs and to make interactive tests
|
||||
// feedable through the simpler `(ch, {}, vp)` path.
|
||||
else if (
|
||||
(key["meta"] || key["ctrl"] || key["alt"]) &&
|
||||
(key["backspace"] || input === "\x7f")
|
||||
) {
|
||||
this.deleteWordLeft();
|
||||
} else if ((key["meta"] || key["ctrl"] || key["alt"]) && key["delete"]) {
|
||||
this.deleteWordRight();
|
||||
} else if (
|
||||
key["backspace"] ||
|
||||
input === "\x7f" ||
|
||||
(key["delete"] && !key["shift"])
|
||||
) {
|
||||
// Treat un‑modified "delete" (the common Mac backspace key) as a
|
||||
// standard backspace. Holding Shift+Delete continues to perform a
|
||||
// forward deletion so we don't lose that capability on keyboards that
|
||||
// expose both behaviours.
|
||||
this.backspace();
|
||||
}
|
||||
// Forward deletion (Fn+Delete on macOS, or Delete key with Shift held after
|
||||
// the branch above) – remove the character *under / to the right* of the
|
||||
// caret, merging lines when at EOL similar to many editors.
|
||||
else if (key["delete"]) {
|
||||
this.del();
|
||||
} else if (input && !key["ctrl"] && !key["meta"]) {
|
||||
this.insert(input);
|
||||
}
|
||||
|
||||
/* printable */
|
||||
|
||||
/* clamp + scroll */
|
||||
this.ensureCursorInRange();
|
||||
this.ensureCursorVisible(vp);
|
||||
|
||||
const cursorMoved =
|
||||
this.cursorRow !== beforeRow || this.cursorCol !== beforeCol;
|
||||
|
||||
if (DEBUG) {
|
||||
dbg("handleInput:after", {
|
||||
cursor: this.getCursor(),
|
||||
text: this.getText(),
|
||||
});
|
||||
}
|
||||
return this.version !== beforeVer || cursorMoved;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user