diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d4b577c..f7f9e8c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -72,8 +72,8 @@ Only update the `Status` field — do not modify any other frontmatter or prompt ## 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. diff --git a/.claude/sessions/2026-03-29.md b/.claude/sessions/2026-03-29.md new file mode 100644 index 0000000..c876b3a --- /dev/null +++ b/.claude/sessions/2026-03-29.md @@ -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 diff --git a/src/ClaudeCli.ts b/src/ClaudeCli.ts index dfa2b61..776545c 100644 --- a/src/ClaudeCli.ts +++ b/src/ClaudeCli.ts @@ -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'; @@ -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; diff --git a/src/editor.ts b/src/editor.ts index eb5c9d6..ee0d65b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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; @@ -157,8 +182,37 @@ 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 } }; @@ -166,8 +220,35 @@ export function moveUp(state: EditorState): EditorState { 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 } }; diff --git a/src/terminal.ts b/src/terminal.ts index 0493412..240103f 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -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;