mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
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:
parent
a59553a881
commit
ca0a86b981
4 changed files with 212 additions and 24 deletions
|
|
@ -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");`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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] || "";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue