Skip to content

Commit 64fc1ee

Browse files
committed
fix(cli): implement sticky column for multiline cursor navigation
Fixes cursor position resetting when navigating up/down through lines of varying lengths. The cursor now remembers its target column during vertical navigation, matching standard text editor behavior. - Add stickyColumnRef to track goal column during up/down navigation - Add getOrSetStickyColumn helper that captures column on first vertical movement and reuses it for subsequent movements - Add desiredIndex parameter to calculateNewCursorPosition - Clear sticky column on any non-vertical key press
1 parent 483aa39 commit 64fc1ee

File tree

2 files changed

+49
-11
lines changed

2 files changed

+49
-11
lines changed

cli/src/components/multiline-input.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,29 @@ export const MultilineInput = forwardRef<
139139
// Refs to track latest values for paste handler (prevents stale closure issues)
140140
const valueRef = useRef(value)
141141
const cursorPositionRef = useRef(cursorPosition)
142+
const stickyColumnRef = useRef<number | null>(null)
143+
144+
// Helper to get or set the sticky column for vertical navigation
145+
const getOrSetStickyColumn = useCallback(
146+
(lineStarts: number[], cursorIsChar: boolean): number => {
147+
if (stickyColumnRef.current != null) {
148+
return stickyColumnRef.current
149+
}
150+
const lineIndex = lineStarts.findLastIndex(
151+
(lineStart) => lineStart <= cursorPositionRef.current,
152+
)
153+
// Account for cursorIsChar offset like cursorDown does
154+
const column =
155+
lineIndex === -1
156+
? 0
157+
: cursorPositionRef.current -
158+
lineStarts[lineIndex] +
159+
(cursorIsChar ? -1 : 0)
160+
stickyColumnRef.current = Math.max(0, column)
161+
return stickyColumnRef.current
162+
},
163+
[],
164+
)
142165

143166
// Keep refs in sync with props
144167
useEffect(() => {
@@ -324,6 +347,11 @@ export const MultilineInput = forwardRef<
324347
}
325348
}
326349

350+
const isVerticalNavKey = key.name === 'up' || key.name === 'down'
351+
if (!isVerticalNavKey) {
352+
stickyColumnRef.current = null
353+
}
354+
327355
const lowerKeyName = (key.name ?? '').toLowerCase()
328356
const ESC = '\x1b'
329357
const isAltLikeModifier = Boolean(
@@ -699,13 +727,18 @@ export const MultilineInput = forwardRef<
699727
// Up arrow (no modifiers)
700728
if (key.name === 'up' && !key.ctrl && !key.meta && !key.option) {
701729
preventKeyDefault(key)
730+
731+
const lineStarts = lineInfo?.lineStarts ?? []
732+
const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight)
733+
702734
onChange({
703735
text: value,
704736
cursorPosition: calculateNewCursorPosition({
705737
cursorPosition,
706-
lineStarts: lineInfo?.lineStarts ?? [],
738+
lineStarts,
707739
cursorIsChar: !shouldHighlight,
708740
direction: 'up',
741+
desiredIndex,
709742
}),
710743
lastEditDueToNav: false,
711744
})
@@ -714,13 +747,17 @@ export const MultilineInput = forwardRef<
714747

715748
// Down arrow (no modifiers)
716749
if (key.name === 'down' && !key.ctrl && !key.meta && !key.option) {
750+
const lineStarts = lineInfo?.lineStarts ?? []
751+
const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight)
752+
717753
onChange({
718754
text: value,
719755
cursorPosition: calculateNewCursorPosition({
720756
cursorPosition,
721-
lineStarts: lineInfo?.lineStarts ?? [],
757+
lineStarts,
722758
cursorIsChar: !shouldHighlight,
723759
direction: 'down',
760+
desiredIndex,
724761
}),
725762
lastEditDueToNav: false,
726763
})

cli/src/utils/word-wrap-utils.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,36 @@
11
function cursorUp(params: {
22
lineStarts: number[]
33
cursorPosition: number
4+
desiredIndex: number
45
}): number {
5-
const { lineStarts, cursorPosition } = params
6+
const { lineStarts, cursorPosition, desiredIndex } = params
67
const lineIndex = lineStarts.findLastIndex((start) => start <= cursorPosition)
78

89
if (lineIndex === -1 || lineIndex === 0) {
910
return 0
1011
}
1112

12-
const index = cursorPosition - lineStarts[lineIndex]
13-
return Math.min(lineStarts[lineIndex] - 1, lineStarts[lineIndex - 1] + index)
13+
const prevLineStart = lineStarts[lineIndex - 1]
14+
const prevLineEndExclusive = lineStarts[lineIndex] - 1
15+
16+
return Math.min(prevLineEndExclusive, prevLineStart + desiredIndex)
1417
}
1518

1619
function cursorDown(params: {
1720
lineStarts: number[]
1821
cursorPosition: number
19-
cursorIsChar: boolean
22+
desiredIndex: number
2023
}): number {
21-
const { lineStarts, cursorPosition, cursorIsChar } = params
24+
const { lineStarts, cursorPosition, desiredIndex } = params
2225
const lineIndex = lineStarts.findLastIndex((start) => start <= cursorPosition)
2326

2427
if (lineIndex === -1 || lineIndex === lineStarts.length - 1) {
2528
return Infinity
2629
}
2730

28-
// Need to account for cursor character itself
29-
const index = cursorPosition - lineStarts[lineIndex] + (cursorIsChar ? -1 : 0)
30-
3131
return Math.min(
3232
(lineStarts[lineIndex + 2] ?? Infinity) - 1,
33-
lineStarts[lineIndex + 1] + index,
33+
lineStarts[lineIndex + 1] + desiredIndex,
3434
)
3535
}
3636

@@ -39,6 +39,7 @@ export function calculateNewCursorPosition(params: {
3939
lineStarts: number[]
4040
cursorIsChar: boolean
4141
direction: 'up' | 'down'
42+
desiredIndex: number
4243
}): number {
4344
const { direction } = params
4445
if (direction === 'up') {

0 commit comments

Comments
 (0)