From a686f61c1d91a7e5c5c4ced3747192b81937888f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 14 Nov 2025 23:19:24 +0100 Subject: [PATCH] feat(tui): improve editor Ctrl/Alt word and line deletion shortcuts - Add Ctrl+W for word deletion (stops at whitespace/punctuation) - Add Ctrl+U for delete to start of line (merges with previous line at col 0) - Change Ctrl+K from delete entire line to delete to end of line (merges with next line at EOL) - Add Option+Backspace support in Ghostty (maps to Ctrl+W via ESC+DEL sequence) - Cmd+Backspace in Ghostty works as Ctrl+U (terminal sends same control code) - Update README and CHANGELOG with new keyboard shortcuts Fixes #2, Fixes #3 --- AGENTS.md | 3 +- packages/ai/src/models.generated.ts | 14 +-- packages/coding-agent/CHANGELOG.md | 9 ++ packages/coding-agent/README.md | 6 +- packages/tui/src/components/editor.ts | 128 ++++++++++++++++++++------ packages/tui/test/key-tester.ts | 91 ++++++++++++++++++ 6 files changed, 215 insertions(+), 36 deletions(-) create mode 100755 packages/tui/test/key-tester.ts diff --git a/AGENTS.md b/AGENTS.md index 97c013d7..c6f35bb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,4 +10,5 @@ - If you are working with an external API, check node_modules for the type definitions as needed instead of assuming things. - Always run `npm run check` in the project's root directory after making code changes. - You must NEVER run `npm run dev` yourself. Doing is means you failed the user hard. -- Do NOT commit unless asked to by the user \ No newline at end of file +- Do NOT commit unless asked to by the user +- Keep you answers short and concise and to the point. \ No newline at end of file diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 7d07bc21..c6a99c4e 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1898,9 +1898,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 1.5, - output: 6, - cacheRead: 0.375, + input: 0.25, + output: 2, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, contextWindow: 400000, @@ -2289,13 +2289,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.6, - output: 2.2, + input: 0.44999999999999996, + output: 1.9, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 204800, - maxTokens: 131072, + contextWindow: 202752, + maxTokens: 4096, } satisfies Model<"openai-completions">, "anthropic/claude-sonnet-4.5": { id: "anthropic/claude-sonnet-4.5", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 85983ff6..7e227030 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Changed + +- Editor: updated keyboard shortcuts to follow Unix conventions: + - **Ctrl+W** deletes the previous word (stopping at whitespace or punctuation) + - **Ctrl+U** deletes from cursor to start of line (at line start, merges with previous line) + - **Ctrl+K** deletes from cursor to end of line (at line end, merges with next line) + - **Option+Backspace** in Ghostty now behaves like **Ctrl+W** (delete word backwards) + - **Cmd+Backspace** in Ghostty now behaves like **Ctrl+U** (delete to start of line) + ## [0.7.8] - 2025-11-13 ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 2855eabc..1147540b 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -131,7 +131,11 @@ Paste multiple lines of text (e.g., code snippets, logs) and they'll be automati ### Keyboard Shortcuts -- **Ctrl+K**: Delete current line +- **Ctrl+W**: Delete word backwards (stops at whitespace or punctuation) +- **Option+Backspace** (Ghostty): Delete word backwards (same as Ctrl+W) +- **Ctrl+U**: Delete to start of line (at line start: merge with previous line) +- **Cmd+Backspace** (Ghostty): Delete to start of line (same as Ctrl+U) +- **Ctrl+K**: Delete to end of line (at line end: merge with next line) - **Ctrl+C**: Clear editor (first press) / Exit pi (second press) - **Tab**: Path completion - **Enter**: Send message diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index da78b471..f9457649 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -231,9 +231,21 @@ export class Editor implements Component { } // Continue with rest of input handling - // Ctrl+K - Delete current line + // Ctrl+K - Delete to end of line if (data.charCodeAt(0) === 11) { - this.deleteCurrentLine(); + this.deleteToEndOfLine(); + } + // Ctrl+U - Delete to start of line + else if (data.charCodeAt(0) === 21) { + this.deleteToStartOfLine(); + } + // Ctrl+W - Delete word backwards + else if (data.charCodeAt(0) === 23) { + this.deleteWordBackwards(); + } + // Option/Alt+Backspace (e.g. Ghostty sends ESC + DEL) + else if (data === "\x1b\x7f") { + this.deleteWordBackwards(); } // Ctrl+A - Move to start of line else if (data.charCodeAt(0) === 1) { @@ -598,6 +610,93 @@ export class Editor implements Component { this.state.cursorCol = currentLine.length; } + private deleteToStartOfLine(): void { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + if (this.state.cursorCol > 0) { + // Delete from start of line up to cursor + this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol); + this.state.cursorCol = 0; + } else if (this.state.cursorLine > 0) { + // At start of line - merge with previous line + const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; + this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; + this.state.lines.splice(this.state.cursorLine, 1); + this.state.cursorLine--; + this.state.cursorCol = previousLine.length; + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private deleteToEndOfLine(): void { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + if (this.state.cursorCol < currentLine.length) { + // Delete from cursor to end of line + this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol); + } else if (this.state.cursorLine < this.state.lines.length - 1) { + // At end of line - merge with next line + const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; + this.state.lines[this.state.cursorLine] = currentLine + nextLine; + this.state.lines.splice(this.state.cursorLine + 1, 1); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private deleteWordBackwards(): void { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + // If at start of line, behave like backspace at column 0 (merge with previous line) + if (this.state.cursorCol === 0) { + if (this.state.cursorLine > 0) { + const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; + this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; + this.state.lines.splice(this.state.cursorLine, 1); + this.state.cursorLine--; + this.state.cursorCol = previousLine.length; + } + } else { + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + + const isWhitespace = (char: string): boolean => /\s/.test(char); + const isPunctuation = (char: string): boolean => { + // Treat obvious code punctuation as boundaries + return /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char); + }; + + let deleteFrom = this.state.cursorCol; + const lastChar = textBeforeCursor[deleteFrom - 1] ?? ""; + + // If immediately on whitespace or punctuation, delete that single boundary char + if (isWhitespace(lastChar) || isPunctuation(lastChar)) { + deleteFrom -= 1; + } else { + // Otherwise, delete a run of non-boundary characters (the "word") + while (deleteFrom > 0) { + const ch = textBeforeCursor[deleteFrom - 1] ?? ""; + if (isWhitespace(ch) || isPunctuation(ch)) { + break; + } + deleteFrom -= 1; + } + } + + this.state.lines[this.state.cursorLine] = + currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol); + this.state.cursorCol = deleteFrom; + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + private handleForwardDelete(): void { const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -618,31 +717,6 @@ export class Editor implements Component { } } - private deleteCurrentLine(): void { - if (this.state.lines.length === 1) { - // Only one line - just clear it - this.state.lines[0] = ""; - this.state.cursorCol = 0; - } else { - // Multiple lines - remove current line - this.state.lines.splice(this.state.cursorLine, 1); - - // Adjust cursor position - if (this.state.cursorLine >= this.state.lines.length) { - // Was on last line, move to new last line - this.state.cursorLine = this.state.lines.length - 1; - } - - // Clamp cursor column to new line length - const newLine = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = Math.min(this.state.cursorCol, newLine.length); - } - - if (this.onChange) { - this.onChange(this.getText()); - } - } - private moveCursor(deltaLine: number, deltaCol: number): void { if (deltaLine !== 0) { const newLine = this.state.cursorLine + deltaLine; diff --git a/packages/tui/test/key-tester.ts b/packages/tui/test/key-tester.ts new file mode 100755 index 00000000..fa3692f4 --- /dev/null +++ b/packages/tui/test/key-tester.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import { ProcessTerminal } from "../src/terminal.js"; +import { type Component, TUI } from "../src/tui.js"; + +/** + * Simple key code logger component + */ +class KeyLogger implements Component { + private log: string[] = []; + private maxLines = 20; + private tui: TUI; + + constructor(tui: TUI) { + this.tui = tui; + } + + handleInput(data: string): void { + // Convert to various representations + const hex = Buffer.from(data).toString("hex"); + const charCodes = Array.from(data) + .map((c) => c.charCodeAt(0)) + .join(", "); + const repr = data + .replace(/\x1b/g, "\\x1b") + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n") + .replace(/\t/g, "\\t") + .replace(/\x7f/g, "\\x7f"); + + const logLine = `Hex: ${hex.padEnd(20)} | Chars: [${charCodes.padEnd(15)}] | Repr: "${repr}"`; + + this.log.push(logLine); + + // Keep only last N lines + if (this.log.length > this.maxLines) { + this.log.shift(); + } + + // Request re-render to show the new log entry + this.tui.requestRender(); + } + + render(width: number): string[] { + const lines: string[] = []; + + // Title + lines.push("=".repeat(width)); + lines.push("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)".padEnd(width)); + lines.push("=".repeat(width)); + lines.push(""); + + // Log entries + for (const entry of this.log) { + lines.push(entry.padEnd(width)); + } + + // Fill remaining space + const remaining = Math.max(0, 25 - lines.length); + for (let i = 0; i < remaining; i++) { + lines.push("".padEnd(width)); + } + + // Footer + lines.push("=".repeat(width)); + lines.push("Test these:".padEnd(width)); + lines.push(" - Option/Alt + Backspace".padEnd(width)); + lines.push(" - Cmd/Ctrl + Backspace".padEnd(width)); + lines.push(" - Regular Backspace".padEnd(width)); + lines.push("=".repeat(width)); + + return lines; + } +} + +// Set up TUI +const terminal = new ProcessTerminal(); +const tui = new TUI(terminal); +const logger = new KeyLogger(tui); + +tui.addChild(logger); +tui.setFocus(logger); + +// Handle Ctrl+C for clean exit +process.on("SIGINT", () => { + tui.stop(); + console.log("\nExiting..."); + process.exit(0); +}); + +// Start the TUI +tui.start();