mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 07:03:25 +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
|
- packages/web-ui/README.md
|
||||||
- We must NEVER have type `any` anywhere, unless absolutely, positively necessary.
|
- 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.
|
- 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 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
|
- Do NOT commit unless asked to by the user
|
||||||
- Keep you answers short and concise and to the point.
|
- Keep you answers short and concise and to the point.
|
||||||
- Do NOT use inline imports ala `await import("./theme/theme.js");`
|
- Do NOT use inline imports ala `await import("./theme/theme.js");`
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
### Fixed
|
### 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:
|
- **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.
|
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.
|
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
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
- **Ctrl+W**: Delete word backwards (stops at whitespace or punctuation)
|
**Navigation:**
|
||||||
- **Option+Backspace** (Ghostty): Delete word backwards (same as Ctrl+W)
|
- **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)
|
- **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+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)
|
- **Ctrl+C**: Clear editor (first press) / Exit pi (second press)
|
||||||
- **Tab**: Path completion
|
|
||||||
- **Shift+Tab**: Cycle thinking level (for reasoning-capable models)
|
- **Shift+Tab**: Cycle thinking level (for reasoning-capable models)
|
||||||
- **Ctrl+P**: Cycle models (use `--models` to scope)
|
- **Ctrl+P**: Cycle models (use `--models` to scope)
|
||||||
- **Ctrl+O**: Toggle tool output expansion (collapsed ↔ full output)
|
- **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
|
## Project Context Files
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ export class Editor implements Component {
|
||||||
|
|
||||||
private theme: EditorTheme;
|
private theme: EditorTheme;
|
||||||
|
|
||||||
|
// Store last render width for cursor navigation
|
||||||
|
private lastWidth: number = 80;
|
||||||
|
|
||||||
// Border color (can be changed dynamically)
|
// Border color (can be changed dynamically)
|
||||||
public borderColor: (str: string) => string;
|
public borderColor: (str: string) => string;
|
||||||
|
|
||||||
|
|
@ -63,6 +66,9 @@ export class Editor implements Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render(width: number): string[] {
|
render(width: number): string[] {
|
||||||
|
// Store width for cursor navigation
|
||||||
|
this.lastWidth = width;
|
||||||
|
|
||||||
const horizontal = this.borderColor("─");
|
const horizontal = this.borderColor("─");
|
||||||
|
|
||||||
// Layout the text - use full width
|
// Layout the text - use full width
|
||||||
|
|
@ -363,6 +369,18 @@ export class Editor implements Component {
|
||||||
// Delete key
|
// Delete key
|
||||||
this.handleForwardDelete();
|
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
|
// Arrow keys
|
||||||
else if (data === "\x1b[A") {
|
else if (data === "\x1b[A") {
|
||||||
// Up
|
// Up
|
||||||
|
|
@ -430,7 +448,12 @@ export class Editor implements Component {
|
||||||
const chunkStart = chunkIndex * maxLineLength;
|
const chunkStart = chunkIndex * maxLineLength;
|
||||||
const chunkEnd = chunkStart + chunk.length;
|
const chunkEnd = chunkStart + chunk.length;
|
||||||
const cursorPos = this.state.cursorCol;
|
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) {
|
if (hasCursorInChunk) {
|
||||||
layoutLines.push({
|
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 {
|
private moveCursor(deltaLine: number, deltaCol: number): void {
|
||||||
|
const width = this.lastWidth;
|
||||||
|
|
||||||
if (deltaLine !== 0) {
|
if (deltaLine !== 0) {
|
||||||
const newLine = this.state.cursorLine + deltaLine;
|
// Build visual line map for navigation
|
||||||
if (newLine >= 0 && newLine < this.state.lines.length) {
|
const visualLines = this.buildVisualLineMap(width);
|
||||||
this.state.cursorLine = newLine;
|
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
||||||
// Clamp cursor column to new line length
|
|
||||||
const line = this.state.lines[this.state.cursorLine] || "";
|
// Calculate column position within current visual line
|
||||||
this.state.cursorCol = Math.min(this.state.cursorCol, line.length);
|
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) {
|
if (deltaCol !== 0) {
|
||||||
// Move column
|
|
||||||
const newCol = this.state.cursorCol + deltaCol;
|
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
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)
|
// Helper method to check if cursor is at start of message (for slash command detection)
|
||||||
private isAtStartOfMessage(): boolean {
|
private isAtStartOfMessage(): boolean {
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue