diff --git a/AGENTS.md b/AGENTS.md index 726193b6..b1317b23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,9 @@ - packages/web-ui/README.md - We must NEVER have type `any` anywhere, unless absolutely, positively necessary. - 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. +- Always run `npm run check` in the project's root directory after making code changes. Do not tail the output, you must get the full output to see ALL errors. - You must NEVER run `npm run dev` yourself. Doing is means you failed the user hard. +- You must NEVER run `npm run build` yourself. Only ever run `npm run check`. - Do NOT commit unless asked to by the user - Keep you answers short and concise and to the point. - Do NOT use inline imports ala `await import("./theme/theme.js");` diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 791d3701..431e808b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- **Editor Cursor Navigation**: Fixed broken up/down arrow key navigation in the editor when lines wrap. Previously, pressing up/down would move between logical lines instead of visual (wrapped) lines, causing the cursor to jump unexpectedly. Now cursor navigation is based on rendered lines. Also fixed a bug where the cursor would appear on two lines simultaneously when positioned at a wrap boundary. Added word by word navigation via Option+Left/Right or Ctrl+Left/Right. ([#61](https://github.com/badlogic/pi-mono/pull/61)) - **Edit Diff Line Number Alignment**: Fixed two issues with diff display in the edit tool: 1. Line numbers were incorrect for edits far from the start of a file (e.g., showing 1, 2, 3 instead of 336, 337, 338). The skip count for context lines was being added after displaying lines instead of before. 2. When diff lines wrapped due to terminal width, the line number prefix lost its leading space alignment, and code indentation (spaces/tabs after line numbers) was lost. Rewrote `splitIntoTokensWithAnsi` in `pi-tui` to preserve whitespace as separate tokens instead of discarding it, so wrapped lines maintain proper alignment and indentation. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1909539e..8e1b3339 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -517,24 +517,31 @@ Change queue mode with `/queue` command. Setting is saved in `~/.pi/agent/settin ### Keyboard Shortcuts -- **Ctrl+W**: Delete word backwards (stops at whitespace or punctuation) -- **Option+Backspace** (Ghostty): Delete word backwards (same as Ctrl+W) +**Navigation:** +- **Arrow keys**: Move cursor (Up/Down navigate visual lines, Left/Right move by character) +- **Option+Left** / **Ctrl+Left**: Move word backwards +- **Option+Right** / **Ctrl+Right**: Move word forwards +- **Ctrl+A** / **Home**: Jump to start of line +- **Ctrl+E** / **End**: Jump to end of line + +**Editing:** +- **Enter**: Send message +- **Shift+Enter** / **Alt+Enter**: Insert new line (multi-line input) +- **Backspace**: Delete character backwards +- **Delete** (or **Fn+Backspace**): Delete character forwards +- **Ctrl+W** / **Option+Backspace**: Delete word backwards (stops at whitespace or punctuation) - **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) + +**Completion:** +- **Tab**: Path completion / Apply autocomplete selection +- **Escape**: Cancel autocomplete (when autocomplete is active) + +**Other:** - **Ctrl+C**: Clear editor (first press) / Exit pi (second press) -- **Tab**: Path completion - **Shift+Tab**: Cycle thinking level (for reasoning-capable models) - **Ctrl+P**: Cycle models (use `--models` to scope) - **Ctrl+O**: Toggle tool output expansion (collapsed ↔ full output) -- **Enter**: Send message -- **Shift+Enter**: Insert new line (multi-line input) -- **Backspace**: Delete character backwards -- **Delete** (or **Fn+Backspace**): Delete character forwards -- **Arrow keys**: Move cursor (Up/Down/Left/Right) -- **Ctrl+A** / **Home** / **Cmd+Left** (macOS): Jump to start of line -- **Ctrl+E** / **End** / **Cmd+Right** (macOS): Jump to end of line -- **Escape**: Cancel autocomplete (when autocomplete is active) ## Project Context Files diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index e27b4b00..ba0bc1d9 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -28,6 +28,9 @@ export class Editor implements Component { private theme: EditorTheme; + // Store last render width for cursor navigation + private lastWidth: number = 80; + // Border color (can be changed dynamically) public borderColor: (str: string) => string; @@ -63,6 +66,9 @@ export class Editor implements Component { } render(width: number): string[] { + // Store width for cursor navigation + this.lastWidth = width; + const horizontal = this.borderColor("─"); // Layout the text - use full width @@ -363,6 +369,18 @@ export class Editor implements Component { // Delete key this.handleForwardDelete(); } + // Word navigation (Option/Alt + Arrow or Ctrl + Arrow) + // Option+Left: \x1b[1;3D or \x1bb + // Option+Right: \x1b[1;3C or \x1bf + // Ctrl+Left: \x1b[1;5D + // Ctrl+Right: \x1b[1;5C + else if (data === "\x1b[1;3D" || data === "\x1bb" || data === "\x1b[1;5D") { + // Word left + this.moveWordBackwards(); + } else if (data === "\x1b[1;3C" || data === "\x1bf" || data === "\x1b[1;5C") { + // Word right + this.moveWordForwards(); + } // Arrow keys else if (data === "\x1b[A") { // Up @@ -430,7 +448,12 @@ export class Editor implements Component { const chunkStart = chunkIndex * maxLineLength; const chunkEnd = chunkStart + chunk.length; const cursorPos = this.state.cursorCol; - const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos <= chunkEnd; + const isLastChunk = chunkIndex === chunks.length - 1; + // For non-last chunks, cursor at chunkEnd belongs to the next chunk + const hasCursorInChunk = + isCurrentLine && + cursorPos >= chunkStart && + (isLastChunk ? cursorPos <= chunkEnd : cursorPos < chunkEnd); if (hasCursorInChunk) { layoutLines.push({ @@ -803,26 +826,182 @@ export class Editor implements Component { } } + /** + * Build a mapping from visual lines to logical positions. + * Returns an array where each element represents a visual line with: + * - logicalLine: index into this.state.lines + * - startCol: starting column in the logical line + * - length: length of this visual line segment + */ + private buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> { + const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = []; + + for (let i = 0; i < this.state.lines.length; i++) { + const line = this.state.lines[i] || ""; + if (line.length === 0) { + // Empty line still takes one visual line + visualLines.push({ logicalLine: i, startCol: 0, length: 0 }); + } else if (line.length <= width) { + visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); + } else { + // Line needs wrapping + for (let pos = 0; pos < line.length; pos += width) { + const segmentLength = Math.min(width, line.length - pos); + visualLines.push({ logicalLine: i, startCol: pos, length: segmentLength }); + } + } + } + + return visualLines; + } + + /** + * Find the visual line index for the current cursor position. + */ + private findCurrentVisualLine( + visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, + ): number { + for (let i = 0; i < visualLines.length; i++) { + const vl = visualLines[i]; + if (!vl) continue; + if (vl.logicalLine === this.state.cursorLine) { + const colInSegment = this.state.cursorCol - vl.startCol; + // Cursor is in this segment if it's within range + // For the last segment of a logical line, cursor can be at length (end position) + const isLastSegmentOfLine = + i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine; + if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) { + return i; + } + } + } + // Fallback: return last visual line + return visualLines.length - 1; + } + private moveCursor(deltaLine: number, deltaCol: number): void { + const width = this.lastWidth; + if (deltaLine !== 0) { - const newLine = this.state.cursorLine + deltaLine; - if (newLine >= 0 && newLine < this.state.lines.length) { - this.state.cursorLine = newLine; - // Clamp cursor column to new line length - const line = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = Math.min(this.state.cursorCol, line.length); + // Build visual line map for navigation + const visualLines = this.buildVisualLineMap(width); + const currentVisualLine = this.findCurrentVisualLine(visualLines); + + // Calculate column position within current visual line + const currentVL = visualLines[currentVisualLine]; + const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0; + + // Move to target visual line + const targetVisualLine = currentVisualLine + deltaLine; + + if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) { + const targetVL = visualLines[targetVisualLine]; + if (targetVL) { + this.state.cursorLine = targetVL.logicalLine; + // Try to maintain visual column position, clamped to line length + const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length); + const logicalLine = this.state.lines[targetVL.logicalLine] || ""; + this.state.cursorCol = Math.min(targetCol, logicalLine.length); + } } } if (deltaCol !== 0) { - // Move column - const newCol = this.state.cursorCol + deltaCol; const currentLine = this.state.lines[this.state.cursorLine] || ""; - const maxCol = currentLine.length; - this.state.cursorCol = Math.max(0, Math.min(maxCol, newCol)); + + if (deltaCol > 0) { + // Moving right + if (this.state.cursorCol < currentLine.length) { + this.state.cursorCol++; + } else if (this.state.cursorLine < this.state.lines.length - 1) { + // Wrap to start of next logical line + this.state.cursorLine++; + this.state.cursorCol = 0; + } + } else { + // Moving left + if (this.state.cursorCol > 0) { + this.state.cursorCol--; + } else if (this.state.cursorLine > 0) { + // Wrap to end of previous logical line + this.state.cursorLine--; + const prevLine = this.state.lines[this.state.cursorLine] || ""; + this.state.cursorCol = prevLine.length; + } + } } } + private isWordBoundary(char: string): boolean { + return /\s/.test(char) || /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char); + } + + private moveWordBackwards(): void { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + // If at start of line, move to end of previous line + if (this.state.cursorCol === 0) { + if (this.state.cursorLine > 0) { + this.state.cursorLine--; + const prevLine = this.state.lines[this.state.cursorLine] || ""; + this.state.cursorCol = prevLine.length; + } + return; + } + + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + let newCol = this.state.cursorCol; + const lastChar = textBeforeCursor[newCol - 1] ?? ""; + + // If immediately on whitespace or punctuation, skip that single boundary char + if (this.isWordBoundary(lastChar)) { + newCol -= 1; + } + + // Now skip the "word" (non-boundary characters) + while (newCol > 0) { + const ch = textBeforeCursor[newCol - 1] ?? ""; + if (this.isWordBoundary(ch)) { + break; + } + newCol -= 1; + } + + this.state.cursorCol = newCol; + } + + private moveWordForwards(): void { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + // If at end of line, move to start of next line + if (this.state.cursorCol >= currentLine.length) { + if (this.state.cursorLine < this.state.lines.length - 1) { + this.state.cursorLine++; + this.state.cursorCol = 0; + } + return; + } + + let newCol = this.state.cursorCol; + const charAtCursor = currentLine[newCol] ?? ""; + + // If on whitespace or punctuation, skip it + if (this.isWordBoundary(charAtCursor)) { + newCol += 1; + } + + // Skip the "word" (non-boundary characters) + while (newCol < currentLine.length) { + const ch = currentLine[newCol] ?? ""; + if (this.isWordBoundary(ch)) { + break; + } + newCol += 1; + } + + this.state.cursorCol = newCol; + } + // Helper method to check if cursor is at start of message (for slash command detection) private isAtStartOfMessage(): boolean { const currentLine = this.state.lines[this.state.cursorLine] || "";