Fix editor cursor navigation for wrapped lines, add word navigation

- Up/down arrows now navigate visual (wrapped) lines instead of logical lines
- Fixed double cursor display at wrap boundaries
- Added word by word navigation via Option+Left/Right or Ctrl+Left/Right
- Updated README keyboard shortcuts documentation

Closes #61
This commit is contained in:
Mario Zechner 2025-11-27 12:08:36 +01:00
parent a59553a881
commit ca0a86b981
4 changed files with 212 additions and 24 deletions

View file

@ -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");`

View file

@ -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.

View file

@ -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

View file

@ -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] || "";