From 78bd9230a8bd0ce18e4c9c3528b6184357245823 Mon Sep 17 00:00:00 2001 From: yao Date: Fri, 29 May 2026 11:01:36 +0800 Subject: [PATCH 1/9] feat(input): add useCursor hook for IME physical cursor tracking --- .../cli/src/ui/components/BaseTextInput.tsx | 38 ++++++++++++++++++- .../cli/src/ui/components/InputPrompt.tsx | 19 +++++++++- .../components/agent-view/AgentComposer.tsx | 2 + 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index df15b564a8..bf9eb48af5 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -20,9 +20,10 @@ */ import type React from 'react'; -import { useCallback } from 'react'; -import { Box, Text } from 'ink'; +import { useCallback, useEffect } from 'react'; +import { Box, Text, useCursor } from 'ink'; import chalk from 'chalk'; +import cliCursor from 'cli-cursor'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -68,6 +69,8 @@ export interface BaseTextInputProps { placeholder?: string; /** Custom prefix node (defaults to `> `). */ prefix?: React.ReactNode; + /** Width of the prefix in terminal columns. Defaults to 2 (for "> "). */ + prefixWidth?: number; /** Border color for the input box. */ borderColor?: string; /** Label rendered on the top border line (right-aligned). Plain string for width calculation. */ @@ -131,6 +134,7 @@ export const BaseTextInput: React.FC = ({ showCursor = true, placeholder, prefix, + prefixWidth = 2, borderColor, topRightLabel, isActive = true, @@ -249,6 +253,36 @@ export const BaseTextInput: React.FC = ({ const [cursorVisualRow, cursorVisualCol] = buffer.visualCursor; const scrollVisualRow = buffer.visualScrollRow; + // ── Physical cursor positioning for IME ── + const { setCursorPosition } = useCursor(); + useEffect(() => { + if (!showCursor) { + setCursorPosition(undefined); + return; + } + // Calculate physical position for the terminal cursor + // x: prefix width + physical width of text before cursor (handles CJK chars) + // y: cursor row relative to viewport + 2 for top border line and bottom border + const relativeRow = cursorVisualRow - scrollVisualRow; + const lineText = linesToRender[relativeRow] || ''; + const textBeforeCursor = cpSlice(lineText, 0, cursorVisualCol); + const physicalCol = stringWidth(textBeforeCursor); + setCursorPosition({ + x: prefixWidth + physicalCol, + y: relativeRow + 2, // +1 for top border line, +1 for bottom border line + }); + // Hide the physical cursor immediately after positioning - we use chalk.inverse for visual cursor + cliCursor.hide(); + }, [ + showCursor, + cursorVisualRow, + cursorVisualCol, + scrollVisualRow, + prefixWidth, + setCursorPosition, + linesToRender, + ]); + const resolvedBorderColor = borderColor ?? theme.border.focused; const resolvedPrefix = prefix ?? ( {'> '} diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 830a766f18..87b05eb052 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -545,7 +545,12 @@ export const InputPrompt: React.FC = ({ setLivePanelFocused(false); return true; } - if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { + if ( + key.sequence && + key.sequence.length === 1 && + !key.ctrl && + !key.meta + ) { setLivePanelFocused(false); return false; } @@ -1575,6 +1580,17 @@ export const InputPrompt: React.FC = ({ ); + // Calculate prefix width for physical cursor positioning + const prefixWidth = shellModeActive + ? reverseSearchActive + ? 5 // "(r:) " = 5 chars + : 2 // "! " = 2 chars + : commandSearchActive + ? 5 // "(r:) " = 5 chars + : showYoloStyling + ? 2 // "* " = 2 chars + : 2; // "> " = 2 chars + return ( <> {attachments.length > 0 && ( @@ -1605,6 +1621,7 @@ export const InputPrompt: React.FC = ({ : placeholder } prefix={prefixNode} + prefixWidth={prefixWidth} borderColor={borderColor} topRightLabel={uiState.sessionName || undefined} isActive={!isEmbeddedShellFocused} diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx index ae5b14c2b5..54c2f40ca2 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx @@ -260,6 +260,7 @@ export const AgentComposer: React.FC = ({ agentId }) => { const prefixNode = ( {isYolo ? '*' : '>'} ); + const prefixWidth = 2; // "> " or "* " = 2 chars return ( @@ -292,6 +293,7 @@ export const AgentComposer: React.FC = ({ agentId }) => { showCursor={isInputActive && !agentTabBarFocused} placeholder={' ' + t('Send a message to this agent')} prefix={prefixNode} + prefixWidth={prefixWidth} borderColor={inputBorderColor} isActive={isInputActive && !agentShellFocused} /> From 55890eb6fe534ccd36aefdede5b0cb4f1b6d6546 Mon Sep 17 00:00:00 2001 From: yao Date: Fri, 29 May 2026 11:59:01 +0800 Subject: [PATCH 2/9] refactor(input): optimize cursor positioning effect --- packages/cli/src/ui/components/BaseTextInput.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index bf9eb48af5..12a8351e9f 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -20,7 +20,7 @@ */ import type React from 'react'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useLayoutEffect } from 'react'; import { Box, Text, useCursor } from 'ink'; import chalk from 'chalk'; import cliCursor from 'cli-cursor'; @@ -255,7 +255,7 @@ export const BaseTextInput: React.FC = ({ // ── Physical cursor positioning for IME ── const { setCursorPosition } = useCursor(); - useEffect(() => { + useLayoutEffect(() => { if (!showCursor) { setCursorPosition(undefined); return; @@ -269,19 +269,11 @@ export const BaseTextInput: React.FC = ({ const physicalCol = stringWidth(textBeforeCursor); setCursorPosition({ x: prefixWidth + physicalCol, - y: relativeRow + 2, // +1 for top border line, +1 for bottom border line + y: relativeRow + 2, }); // Hide the physical cursor immediately after positioning - we use chalk.inverse for visual cursor cliCursor.hide(); - }, [ - showCursor, - cursorVisualRow, - cursorVisualCol, - scrollVisualRow, - prefixWidth, - setCursorPosition, - linesToRender, - ]); + }); const resolvedBorderColor = borderColor ?? theme.border.focused; const resolvedPrefix = prefix ?? ( From 544281ee45e73b913bb35174550359079ca4ee45 Mon Sep 17 00:00:00 2001 From: yao Date: Fri, 29 May 2026 13:26:02 +0800 Subject: [PATCH 3/9] feat(input): move setCursorPosition to render phase for immediate cursor positioning --- .../cli/src/ui/components/BaseTextInput.tsx | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 12a8351e9f..0285a1bb75 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -20,10 +20,9 @@ */ import type React from 'react'; -import { useCallback, useLayoutEffect } from 'react'; +import { useCallback } from 'react'; import { Box, Text, useCursor } from 'ink'; import chalk from 'chalk'; -import cliCursor from 'cli-cursor'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -254,15 +253,10 @@ export const BaseTextInput: React.FC = ({ const scrollVisualRow = buffer.visualScrollRow; // ── Physical cursor positioning for IME ── + // Called in render phase so Ink's useCursor (which uses useInsertionEffect) + // propagates the position before onRender — no one-frame lag on first mount. const { setCursorPosition } = useCursor(); - useLayoutEffect(() => { - if (!showCursor) { - setCursorPosition(undefined); - return; - } - // Calculate physical position for the terminal cursor - // x: prefix width + physical width of text before cursor (handles CJK chars) - // y: cursor row relative to viewport + 2 for top border line and bottom border + if (showCursor) { const relativeRow = cursorVisualRow - scrollVisualRow; const lineText = linesToRender[relativeRow] || ''; const textBeforeCursor = cpSlice(lineText, 0, cursorVisualCol); @@ -271,9 +265,9 @@ export const BaseTextInput: React.FC = ({ x: prefixWidth + physicalCol, y: relativeRow + 2, }); - // Hide the physical cursor immediately after positioning - we use chalk.inverse for visual cursor - cliCursor.hide(); - }); + } else { + setCursorPosition(undefined); + } const resolvedBorderColor = borderColor ?? theme.border.focused; const resolvedPrefix = prefix ?? ( From 454f904361ce754ebae30df24095c15317f42148 Mon Sep 17 00:00:00 2001 From: yao Date: Sat, 30 May 2026 16:15:36 +0800 Subject: [PATCH 4/9] fix(input): calculate absolute cursor position by walking yoga tree --- .../cli/src/ui/components/BaseTextInput.tsx | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 0285a1bb75..1a3a73ade4 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -20,8 +20,8 @@ */ import type React from 'react'; -import { useCallback } from 'react'; -import { Box, Text, useCursor } from 'ink'; +import { useCallback, useRef } from 'react'; +import { Box, Text, useCursor, useBoxMetrics } from 'ink'; import chalk from 'chalk'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; @@ -253,20 +253,36 @@ export const BaseTextInput: React.FC = ({ const scrollVisualRow = buffer.visualScrollRow; // ── Physical cursor positioning for IME ── - // Called in render phase so Ink's useCursor (which uses useInsertionEffect) - // propagates the position before onRender — no one-frame lag on first mount. + // Walk up the yoga tree to get absolute coordinates from the dynamic + // output origin (not just relative to parent). This keeps the cursor + // correct when sibling components appear/disappear above us. + const rootRef = useRef(null); + const { hasMeasured } = useBoxMetrics(rootRef); const { setCursorPosition } = useCursor(); - if (showCursor) { + if (hasMeasured && rootRef.current) { + let absTop = 0; + let absLeft = 0; + let node: unknown = rootRef.current; + while (node) { + const n = node as { + yogaNode?: { getComputedLayout(): { top: number; left: number } }; + parentNode?: unknown; + }; + const layout = n.yogaNode?.getComputedLayout(); + if (layout) { + absTop += layout.top; + absLeft += layout.left; + } + node = n.parentNode; + } const relativeRow = cursorVisualRow - scrollVisualRow; const lineText = linesToRender[relativeRow] || ''; const textBeforeCursor = cpSlice(lineText, 0, cursorVisualCol); const physicalCol = stringWidth(textBeforeCursor); setCursorPosition({ - x: prefixWidth + physicalCol, - y: relativeRow + 2, + x: absLeft + prefixWidth + physicalCol, + y: absTop + relativeRow + 1, }); - } else { - setCursorPosition(undefined); } const resolvedBorderColor = borderColor ?? theme.border.focused; @@ -284,7 +300,7 @@ export const BaseTextInput: React.FC = ({ : '─'.repeat(columns); return ( - + {topBorderLine} From 51d83aa5d0b47d7a4e6508ac608b7732a6db3081 Mon Sep 17 00:00:00 2001 From: yao Date: Sat, 30 May 2026 19:28:13 +0800 Subject: [PATCH 5/9] fix(input): use addLayoutListener instead of useCursor for zero-jitter cursor positioning --- .../cli/src/ui/components/BaseTextInput.tsx | 127 +++++++++++++----- 1 file changed, 91 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 1a3a73ade4..130670045d 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -19,9 +19,12 @@ * and AgentComposer (with minimal customization). */ -import type React from 'react'; -import { useCallback, useRef } from 'react'; -import { Box, Text, useCursor, useBoxMetrics } from 'ink'; +import type { Context, ReactNode } from 'react'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { useCallback, useContext, useEffect, useRef } from 'react'; +import { Box, Text, useBoxMetrics } from 'ink'; import chalk from 'chalk'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; @@ -31,6 +34,26 @@ import stringWidth from 'string-width'; import { cpSlice, cpLen } from '../utils/textUtils.js'; import { theme } from '../semantic-colors.js'; +// ─── Ink internals (not in package exports map) ──────────── +// Ink only exports its main entry. We resolve that via require.resolve +// (which IS allowed), derive the package root, then use file:// import() +// to bypass the exports-map restriction for internal subpaths. +const _req = createRequire(import.meta.url); +const _inkRoot = dirname(dirname(_req.resolve('ink'))); +const inkDom = (await import( + pathToFileURL(resolve(_inkRoot, 'build', 'dom.js')).href +)) as { + addLayoutListener: (rootNode: unknown, listener: () => void) => () => void; +}; +const inkCursorCtx = (await import( + pathToFileURL(resolve(_inkRoot, 'build', 'components', 'CursorContext.js')) + .href +)) as { + default: Context<{ + setCursorPosition(pos: { x: number; y: number } | undefined): void; + }>; +}; + // ─── Types ────────────────────────────────────────────────── export interface RenderLineOptions { @@ -67,7 +90,7 @@ export interface BaseTextInputProps { /** Placeholder text shown when the buffer is empty. */ placeholder?: string; /** Custom prefix node (defaults to `> `). */ - prefix?: React.ReactNode; + prefix?: ReactNode; /** Width of the prefix in terminal columns. Defaults to 2 (for "> "). */ prefixWidth?: number; /** Border color for the input box. */ @@ -80,7 +103,7 @@ export interface BaseTextInputProps { * Custom line renderer for advanced rendering (e.g. syntax highlighting). * When not provided, lines are rendered as plain text with cursor overlay. */ - renderLine?: (opts: RenderLineOptions) => React.ReactNode; + renderLine?: (opts: RenderLineOptions) => ReactNode; } // ─── Default line renderer ────────────────────────────────── @@ -94,7 +117,7 @@ export function defaultRenderLine({ isOnCursorLine, cursorCol, showCursor, -}: RenderLineOptions): React.ReactNode { +}: RenderLineOptions): ReactNode { if (!isOnCursorLine || !showCursor) { return {lineText || ' '}; } @@ -124,9 +147,22 @@ export function defaultRenderLine({ ); } +// ─── Helpers ──────────────────────────────────────────────── + +// Walk up Ink's internal DOM tree to find the root node (ink-root). +// addLayoutListener requires the root node specifically. +function findRootNode( + node: (Record & { parentNode?: unknown }) | null, +): Record | undefined { + if (!node) return undefined; + if (!node.parentNode) + return node['nodeName'] === 'ink-root' ? node : undefined; + return findRootNode(node.parentNode as Record); +} + // ─── Component ────────────────────────────────────────────── -export const BaseTextInput: React.FC = ({ +export const BaseTextInput = ({ buffer, onSubmit, onKeypress, @@ -138,7 +174,7 @@ export const BaseTextInput: React.FC = ({ topRightLabel, isActive = true, renderLine = defaultRenderLine, -}) => { +}: BaseTextInputProps): ReactNode => { // ── Keyboard handling ── const handleKey = useCallback( @@ -253,37 +289,56 @@ export const BaseTextInput: React.FC = ({ const scrollVisualRow = buffer.visualScrollRow; // ── Physical cursor positioning for IME ── - // Walk up the yoga tree to get absolute coordinates from the dynamic - // output origin (not just relative to parent). This keeps the cursor - // correct when sibling components appear/disappear above us. + // addLayoutListener fires in resetAfterCommit AFTER calculateLayout() + // but BEFORE onRender() — yoga layout is fresh, terminal not yet written. + // addLayoutListener requires the root node (ink-root), not the component + // node. We find it by walking up the Ink DOM parent chain. const rootRef = useRef(null); - const { hasMeasured } = useBoxMetrics(rootRef); - const { setCursorPosition } = useCursor(); - if (hasMeasured && rootRef.current) { - let absTop = 0; - let absLeft = 0; - let node: unknown = rootRef.current; - while (node) { - const n = node as { - yogaNode?: { getComputedLayout(): { top: number; left: number } }; - parentNode?: unknown; - }; - const layout = n.yogaNode?.getComputedLayout(); - if (layout) { - absTop += layout.top; - absLeft += layout.left; + useBoxMetrics(rootRef); + const cursorCtx = useContext(inkCursorCtx.default); + + useEffect(() => { + const rootNode = findRootNode(rootRef.current); + if (!rootNode) return; + const unsub = inkDom.addLayoutListener(rootNode, () => { + const node = rootRef.current; + if (!node) return; + let absTop = 0; + let absLeft = 0; + let n: unknown = node; + while (n) { + const nd = n as { + yogaNode?: { getComputedLayout(): { top: number; left: number } }; + parentNode?: unknown; + }; + const layout = nd.yogaNode?.getComputedLayout(); + if (layout) { + absTop += layout.top; + absLeft += layout.left; + } + n = nd.parentNode; } - node = n.parentNode; - } - const relativeRow = cursorVisualRow - scrollVisualRow; - const lineText = linesToRender[relativeRow] || ''; - const textBeforeCursor = cpSlice(lineText, 0, cursorVisualCol); - const physicalCol = stringWidth(textBeforeCursor); - setCursorPosition({ - x: absLeft + prefixWidth + physicalCol, - y: absTop + relativeRow + 1, + const relativeRow = cursorVisualRow - scrollVisualRow; + const lineText = linesToRender[relativeRow] || ''; + const textBeforeCursor = cpSlice(lineText, 0, cursorVisualCol); + const physicalCol = stringWidth(textBeforeCursor); + cursorCtx.setCursorPosition({ + x: absLeft + prefixWidth + physicalCol, + y: absTop + relativeRow + 1, + }); }); - } + return () => { + unsub(); + cursorCtx.setCursorPosition(undefined); + }; + }, [ + cursorCtx, + cursorVisualRow, + cursorVisualCol, + scrollVisualRow, + linesToRender, + prefixWidth, + ]); const resolvedBorderColor = borderColor ?? theme.border.focused; const resolvedPrefix = prefix ?? ( From 1325467be983f270eb455d8d574aca916a6130e1 Mon Sep 17 00:00:00 2001 From: yao Date: Sun, 31 May 2026 11:25:41 +0800 Subject: [PATCH 6/9] perf(input): stable addLayoutListener subscription and skip redundant cursor updates --- .../cli/src/ui/components/BaseTextInput.tsx | 62 ++++++++++++++----- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 130670045d..f87a63ae27 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -24,7 +24,7 @@ import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { useCallback, useContext, useEffect, useRef } from 'react'; -import { Box, Text, useBoxMetrics } from 'ink'; +import { Box, Text } from 'ink'; import chalk from 'chalk'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; @@ -294,13 +294,45 @@ export const BaseTextInput = ({ // addLayoutListener requires the root node (ink-root), not the component // node. We find it by walking up the Ink DOM parent chain. const rootRef = useRef(null); - useBoxMetrics(rootRef); const cursorCtx = useContext(inkCursorCtx.default); + // Use a ref to hold mutable state so the layout listener callback + // always reads the latest values without needing to resubscribe. + const stateRef = useRef({ + showCursor, + cursorVisualRow, + cursorVisualCol, + scrollVisualRow, + linesToRender, + prefixWidth, + }); + stateRef.current = { + showCursor, + cursorVisualRow, + cursorVisualCol, + scrollVisualRow, + linesToRender, + prefixWidth, + }; + useEffect(() => { const rootNode = findRootNode(rootRef.current); if (!rootNode) return; + let lastPos = ''; const unsub = inkDom.addLayoutListener(rootNode, () => { + const { + showCursor: sc, + cursorVisualRow: vr, + cursorVisualCol: vc, + scrollVisualRow: sr, + linesToRender: lt, + prefixWidth: pw, + } = stateRef.current; + if (!sc) { + cursorCtx.setCursorPosition(undefined); + lastPos = ''; + return; + } const node = rootRef.current; if (!node) return; let absTop = 0; @@ -318,27 +350,23 @@ export const BaseTextInput = ({ } n = nd.parentNode; } - const relativeRow = cursorVisualRow - scrollVisualRow; - const lineText = linesToRender[relativeRow] || ''; - const textBeforeCursor = cpSlice(lineText, 0, cursorVisualCol); + const relativeRow = vr - sr; + const lineText = lt[relativeRow] || ''; + const textBeforeCursor = cpSlice(lineText, 0, vc); const physicalCol = stringWidth(textBeforeCursor); - cursorCtx.setCursorPosition({ - x: absLeft + prefixWidth + physicalCol, - y: absTop + relativeRow + 1, - }); + const x = absLeft + pw + physicalCol; + const y = absTop + relativeRow + 1; + const pos = `${x},${y}`; + if (pos !== lastPos) { + lastPos = pos; + cursorCtx.setCursorPosition({ x, y }); + } }); return () => { unsub(); cursorCtx.setCursorPosition(undefined); }; - }, [ - cursorCtx, - cursorVisualRow, - cursorVisualCol, - scrollVisualRow, - linesToRender, - prefixWidth, - ]); + }, [cursorCtx]); const resolvedBorderColor = borderColor ?? theme.border.focused; const resolvedPrefix = prefix ?? ( From 13a438024f5de5deb58ddd763918c95466e4c5b6 Mon Sep 17 00:00:00 2001 From: yao Date: Sun, 31 May 2026 11:48:11 +0800 Subject: [PATCH 7/9] fix(input): revert lastPos dedup that broke cursorDirty one-shot flag --- packages/cli/src/ui/components/BaseTextInput.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index f87a63ae27..a0503c81cf 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -24,7 +24,7 @@ import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { useCallback, useContext, useEffect, useRef } from 'react'; -import { Box, Text } from 'ink'; +import { Box, Text, useBoxMetrics } from 'ink'; import chalk from 'chalk'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; @@ -294,6 +294,7 @@ export const BaseTextInput = ({ // addLayoutListener requires the root node (ink-root), not the component // node. We find it by walking up the Ink DOM parent chain. const rootRef = useRef(null); + useBoxMetrics(rootRef); const cursorCtx = useContext(inkCursorCtx.default); // Use a ref to hold mutable state so the layout listener callback @@ -318,7 +319,6 @@ export const BaseTextInput = ({ useEffect(() => { const rootNode = findRootNode(rootRef.current); if (!rootNode) return; - let lastPos = ''; const unsub = inkDom.addLayoutListener(rootNode, () => { const { showCursor: sc, @@ -330,7 +330,6 @@ export const BaseTextInput = ({ } = stateRef.current; if (!sc) { cursorCtx.setCursorPosition(undefined); - lastPos = ''; return; } const node = rootRef.current; @@ -354,13 +353,10 @@ export const BaseTextInput = ({ const lineText = lt[relativeRow] || ''; const textBeforeCursor = cpSlice(lineText, 0, vc); const physicalCol = stringWidth(textBeforeCursor); - const x = absLeft + pw + physicalCol; - const y = absTop + relativeRow + 1; - const pos = `${x},${y}`; - if (pos !== lastPos) { - lastPos = pos; - cursorCtx.setCursorPosition({ x, y }); - } + cursorCtx.setCursorPosition({ + x: absLeft + pw + physicalCol, + y: absTop + relativeRow + 1, + }); }); return () => { unsub(); From 886004e7992b53ca66dbe4416444acebdbe39c20 Mon Sep 17 00:00:00 2001 From: yao Date: Sun, 31 May 2026 14:52:43 +0800 Subject: [PATCH 8/9] feat(input): use patch-package to expose Ink internals for IME cursor positioning --- package-lock.json | 195 ++++++++++++++++++ package.json | 2 + .../cli/src/ui/components/BaseTextInput.tsx | 35 +--- patches/ink+7.0.3.patch | 22 ++ 4 files changed, 226 insertions(+), 28 deletions(-) create mode 100644 patches/ink+7.0.3.patch diff --git a/package-lock.json b/package-lock.json index f1346a56c8..d2f4de2018 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "mock-fs": "^5.5.0", "msw": "^2.10.4", "npm-run-all": "^4.1.5", + "patch-package": "^8.0.1", "prettier": "^3.5.3", "react-devtools-core": "^6.1.5", "semver": "^7.7.2", @@ -4990,6 +4991,13 @@ "addons/*" ] }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -6230,6 +6238,22 @@ } } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -8729,6 +8753,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -10966,6 +11000,26 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -11009,6 +11063,16 @@ "node": ">= 10.0.0" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsonrepair": { "version": "3.13.1", "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz", @@ -11065,6 +11129,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -12813,6 +12887,107 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/patch-package/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -14616,6 +14791,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/slice-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", @@ -15600,6 +15785,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/package.json b/package.json index d328913cce..2f6b9462d2 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "build:sdk:python": "python3 -m build packages/sdk-python", "check-i18n": "npm run check-i18n --workspace=packages/cli", "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", + "postinstall": "patch-package", "prepare": "husky && npm run build && npm run bundle", "prepare:package": "node scripts/prepare-package.js", "package:hosted-installation": "node scripts/build-hosted-installation-assets.js", @@ -126,6 +127,7 @@ "mock-fs": "^5.5.0", "msw": "^2.10.4", "npm-run-all": "^4.1.5", + "patch-package": "^8.0.1", "prettier": "^3.5.3", "react-devtools-core": "^6.1.5", "semver": "^7.7.2", diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index a0503c81cf..5651993d51 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -19,12 +19,11 @@ * and AgentComposer (with minimal customization). */ -import type { Context, ReactNode } from 'react'; -import { createRequire } from 'node:module'; -import { dirname, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import type { ReactNode } from 'react'; import { useCallback, useContext, useEffect, useRef } from 'react'; import { Box, Text, useBoxMetrics } from 'ink'; +import { addLayoutListener, type DOMElement } from 'ink/dom'; +import CursorContext from 'ink/components/CursorContext'; import chalk from 'chalk'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; @@ -34,26 +33,6 @@ import stringWidth from 'string-width'; import { cpSlice, cpLen } from '../utils/textUtils.js'; import { theme } from '../semantic-colors.js'; -// ─── Ink internals (not in package exports map) ──────────── -// Ink only exports its main entry. We resolve that via require.resolve -// (which IS allowed), derive the package root, then use file:// import() -// to bypass the exports-map restriction for internal subpaths. -const _req = createRequire(import.meta.url); -const _inkRoot = dirname(dirname(_req.resolve('ink'))); -const inkDom = (await import( - pathToFileURL(resolve(_inkRoot, 'build', 'dom.js')).href -)) as { - addLayoutListener: (rootNode: unknown, listener: () => void) => () => void; -}; -const inkCursorCtx = (await import( - pathToFileURL(resolve(_inkRoot, 'build', 'components', 'CursorContext.js')) - .href -)) as { - default: Context<{ - setCursorPosition(pos: { x: number; y: number } | undefined): void; - }>; -}; - // ─── Types ────────────────────────────────────────────────── export interface RenderLineOptions { @@ -153,10 +132,10 @@ export function defaultRenderLine({ // addLayoutListener requires the root node specifically. function findRootNode( node: (Record & { parentNode?: unknown }) | null, -): Record | undefined { +): DOMElement | undefined { if (!node) return undefined; if (!node.parentNode) - return node['nodeName'] === 'ink-root' ? node : undefined; + return node['nodeName'] === 'ink-root' ? (node as DOMElement) : undefined; return findRootNode(node.parentNode as Record); } @@ -295,7 +274,7 @@ export const BaseTextInput = ({ // node. We find it by walking up the Ink DOM parent chain. const rootRef = useRef(null); useBoxMetrics(rootRef); - const cursorCtx = useContext(inkCursorCtx.default); + const cursorCtx = useContext(CursorContext); // Use a ref to hold mutable state so the layout listener callback // always reads the latest values without needing to resubscribe. @@ -319,7 +298,7 @@ export const BaseTextInput = ({ useEffect(() => { const rootNode = findRootNode(rootRef.current); if (!rootNode) return; - const unsub = inkDom.addLayoutListener(rootNode, () => { + const unsub = addLayoutListener(rootNode, () => { const { showCursor: sc, cursorVisualRow: vr, diff --git a/patches/ink+7.0.3.patch b/patches/ink+7.0.3.patch new file mode 100644 index 0000000000..9a0b3405e4 --- /dev/null +++ b/patches/ink+7.0.3.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/ink/package.json b/node_modules/ink/package.json +--- a/node_modules/ink/package.json ++++ b/node_modules/ink/package.json +@@ -11,8 +11,16 @@ + }, + "type": "module", + "exports": { +- "types": "./build/index.d.ts", +- "default": "./build/index.js" ++ ".": { ++ "types": "./build/index.d.ts", ++ "default": "./build/index.js" ++ }, ++ "./dom": { ++ "default": "./build/dom.js" ++ }, ++ "./components/CursorContext": { ++ "default": "./build/components/CursorContext.js" ++ } + }, + "engines": { + "node": ">=22" From d2548cfb46443fa0579d846ba75c0c6c88aeeff2 Mon Sep 17 00:00:00 2001 From: yao Date: Sun, 31 May 2026 16:04:07 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix(input):=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20prefixWidth,=20remove=20useBoxMetrics,=20pin=20i?= =?UTF-8?q?nk=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Shaojin Wen --- packages/cli/package.json | 2 +- packages/cli/src/ui/components/BaseTextInput.tsx | 3 +-- packages/cli/src/ui/components/InputPrompt.tsx | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index a8f615c051..b71e078da9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -62,7 +62,7 @@ "fzf": "^0.5.2", "glob": "^10.5.0", "highlight.js": "^11.11.1", - "ink": "^7.0.3", + "ink": "7.0.3", "ink-gradient": "^3.0.0", "ink-link": "^4.1.0", "ink-spinner": "^5.0.0", diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 5651993d51..b2a2390245 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -21,7 +21,7 @@ import type { ReactNode } from 'react'; import { useCallback, useContext, useEffect, useRef } from 'react'; -import { Box, Text, useBoxMetrics } from 'ink'; +import { Box, Text } from 'ink'; import { addLayoutListener, type DOMElement } from 'ink/dom'; import CursorContext from 'ink/components/CursorContext'; import chalk from 'chalk'; @@ -273,7 +273,6 @@ export const BaseTextInput = ({ // addLayoutListener requires the root node (ink-root), not the component // node. We find it by walking up the Ink DOM parent chain. const rootRef = useRef(null); - useBoxMetrics(rootRef); const cursorCtx = useContext(CursorContext); // Use a ref to hold mutable state so the layout listener callback diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 87b05eb052..b66af376be 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1583,10 +1583,10 @@ export const InputPrompt: React.FC = ({ // Calculate prefix width for physical cursor positioning const prefixWidth = shellModeActive ? reverseSearchActive - ? 5 // "(r:) " = 5 chars + ? 6 // "(r:) " (inner) + " " (outer) = 6 cols : 2 // "! " = 2 chars : commandSearchActive - ? 5 // "(r:) " = 5 chars + ? 6 // "(r:) " (inner) + " " (outer) = 6 cols : showYoloStyling ? 2 // "* " = 2 chars : 2; // "> " = 2 chars