Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ Only update the `Status` field — do not modify any other frontmatter or prompt

<!-- BEGIN:REPO:current-state -->
## Current State
Branch: `fix/lone-surrogate-sanitisation`
In-progress: Nothing. PR #143 (fix #141, lone surrogate sanitisation) open, auto-merge enabled.
Branch: `fix/editor-cursor-wrapped-lines`
In-progress: Nothing. PR #144 (fix #135, cursor navigation in wrapped editor lines) open, auto-merge enabled.
<!-- END:REPO:current-state -->

<!-- BEGIN:REPO:architecture -->
Expand Down
9 changes: 9 additions & 0 deletions .claude/sessions/2026-03-29.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Session Log: 2026-03-29

### 00:23 - fix/editor-cursor-wrapped-lines (#135) Stage 2

- Did: Staged and committed wrapped-line cursor fixes from phase 1, pushed branch, created PR #144 with Closes #135, auto-merge enabled. Build and signature checks passed; CodeQL still running.
- Files: `src/ClaudeCli.ts`, `src/editor.ts`, `src/terminal.ts`
- Decisions: No architectural decisions. Phase 1 (code changes, type-check, build, test) was completed by the previous session; this session handled commit, push, and PR.
- Next: Await PR #144 auto-merge.
- Violations: None
21 changes: 17 additions & 4 deletions src/ClaudeCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { resolve } from 'node:path';
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import type { DocumentBlockParam, ImageBlockParam, SearchResultBlockParam, TextBlockParam, ToolReferenceBlockParam } from '@anthropic-ai/sdk/resources';
import { ExecInputSchema } from '@shellicar/mcp-exec';
import stringWidth from 'string-width';
import { AppState } from './AppState.js';
import { AttachmentStore } from './AttachmentStore.js';
import { AuditWriter } from './AuditWriter.js';
Expand Down Expand Up @@ -658,12 +659,24 @@ export class ClaudeCli {
case 'right':
this.editor = moveRight(this.editor);
break;
case 'up':
this.editor = moveUp(this.editor);
case 'up': {
const busyUp = this.appState.phase !== 'idle';
const promptUp = this.prompts.isOtherMode ? '> ' : this.commandMode.active ? '🔧 ' : busyUp ? '⏳ ' : '💬 ';
const colsUp = process.stdout.columns || 80;
const pwUp = stringWidth(promptUp);
const prefixWidthsUp = this.editor.lines.map((_, i) => (i === 0 ? pwUp : 2));
this.editor = moveUp(this.editor, colsUp, prefixWidthsUp);
break;
case 'down':
this.editor = moveDown(this.editor);
}
case 'down': {
const busyDown = this.appState.phase !== 'idle';
const promptDown = this.prompts.isOtherMode ? '> ' : this.commandMode.active ? '🔧 ' : busyDown ? '⏳ ' : '💬 ';
const colsDown = process.stdout.columns || 80;
const pwDown = stringWidth(promptDown);
const prefixWidthsDown = this.editor.lines.map((_, i) => (i === 0 ? pwDown : 2));
this.editor = moveDown(this.editor, colsDown, prefixWidthsDown);
break;
}
case 'home':
this.editor = moveHome(this.editor);
break;
Expand Down
87 changes: 84 additions & 3 deletions src/editor.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
/**
* Pure text buffer with cursor management.
* No I/O — just data manipulation.
* No I/O. Just data manipulation.
*/

import stringWidth from 'string-width';

const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });

/**
* Returns the byte offset in `text` where cumulative visual width first reaches
* or exceeds `targetWidth`. If `targetWidth` is beyond the end of the text, the
* full string length is returned.
*/
function byteOffsetAtVisualWidth(text: string, targetWidth: number): number {
if (targetWidth <= 0) {
return 0;
}
const segs = [...segmenter.segment(text)];
let w = 0;
let lastEnd = 0;
for (const seg of segs) {
const sw = stringWidth(seg.segment);
if (w + sw > targetWidth) {
break;
}
w += sw;
lastEnd = seg.index + seg.segment.length;
}
return lastEnd;
}

export interface CursorPosition {
row: number;
col: number;
Expand Down Expand Up @@ -157,17 +182,73 @@ export function moveRight(state: EditorState): EditorState {
return state;
}

export function moveUp(state: EditorState): EditorState {
export function moveUp(state: EditorState, columns?: number, prefixWidths?: number[]): EditorState {
const { lines, cursor } = state;

if (columns !== undefined && prefixWidths !== undefined) {
const pw = prefixWidths[cursor.row];
const line = lines[cursor.row];
const visualOffset = pw + stringWidth(line.slice(0, cursor.col));
const subRow = Math.floor(visualOffset / columns);
const termCol = visualOffset % columns;

if (subRow > 0) {
const targetVisual = (subRow - 1) * columns + termCol;
const textTarget = Math.max(0, targetVisual - pw);
return { lines, cursor: { row: cursor.row, col: byteOffsetAtVisualWidth(line, textTarget) } };
}

if (cursor.row > 0) {
const prevRow = cursor.row - 1;
const prevPw = prefixWidths[prevRow];
const prevLine = lines[prevRow];
const prevTotalWidth = prevPw + stringWidth(prevLine);
const prevLineSubRows = Math.max(1, Math.ceil(prevTotalWidth / columns));
const lastSubRow = prevLineSubRows - 1;
const targetVisual = Math.min(lastSubRow * columns + termCol, prevTotalWidth);
const textTarget = Math.max(0, targetVisual - prevPw);
return { lines, cursor: { row: prevRow, col: byteOffsetAtVisualWidth(prevLine, textTarget) } };
}

return state;
}

if (cursor.row > 0) {
const newCol = Math.min(cursor.col, lines[cursor.row - 1].length);
return { lines, cursor: { row: cursor.row - 1, col: newCol } };
}
return state;
}

export function moveDown(state: EditorState): EditorState {
export function moveDown(state: EditorState, columns?: number, prefixWidths?: number[]): EditorState {
const { lines, cursor } = state;

if (columns !== undefined && prefixWidths !== undefined) {
const pw = prefixWidths[cursor.row];
const line = lines[cursor.row];
const visualOffset = pw + stringWidth(line.slice(0, cursor.col));
const subRow = Math.floor(visualOffset / columns);
const termCol = visualOffset % columns;
const totalWidth = pw + stringWidth(line);
const lineSubRows = Math.max(1, Math.ceil(totalWidth / columns));

if (subRow + 1 < lineSubRows) {
const targetVisual = Math.min((subRow + 1) * columns + termCol, totalWidth);
const textTarget = Math.max(0, targetVisual - pw);
return { lines, cursor: { row: cursor.row, col: byteOffsetAtVisualWidth(line, textTarget) } };
}

if (cursor.row < lines.length - 1) {
const nextRow = cursor.row + 1;
const nextPw = prefixWidths[nextRow];
const nextLine = lines[nextRow];
const textTarget = Math.max(0, termCol - nextPw);
return { lines, cursor: { row: nextRow, col: byteOffsetAtVisualWidth(nextLine, textTarget) } };
}

return state;
}

if (cursor.row < lines.length - 1) {
const newCol = Math.min(cursor.col, lines[cursor.row + 1].length);
return { lines, cursor: { row: cursor.row + 1, col: newCol } };
Expand Down
14 changes: 8 additions & 6 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,15 +317,17 @@ export class Terminal {
// Clear any leftover lines from previous render
output += clearDown;

// Position cursor within editor
this.cursorLinesFromBottom = 0;
for (let i = this.editorContent.lines.length - 1; i > this.editorContent.cursorRow; i--) {
this.cursorLinesFromBottom += Math.max(1, Math.ceil(stringWidth(this.editorContent.lines[i]) / columns));
}
// Position cursor within editor.
// cursorRow is a terminal row count (accounts for wrapping); editorScreenLines
// is the total terminal rows the editor occupies. Rows below cursor = the
// difference.
this.cursorLinesFromBottom = editorScreenLines - this.editorContent.cursorRow - 1;
if (this.cursorLinesFromBottom > 0) {
output += cursorUp(this.cursorLinesFromBottom);
}
output += cursorTo(this.editorContent.cursorCol);
// cursorCol is the flat visual offset including prefix. Use modulo to get
// the column within the current terminal row when the line wraps.
output += cursorTo(this.editorContent.cursorCol % columns);
output += this.cursorHidden ? hideCursorSeq : showCursor;

this.stickyLineCount = statusScreenLines + attachmentScreenLines + previewScreenLines + questionScreenLines + editorScreenLines;
Expand Down
Loading