@ -110,6 +110,9 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
private volatile int lastRenderedItemCount = 0 ;
private volatile int lastRenderedItemCount = 0 ;
private volatile int lastMaxVisibleLines = 20 ;
private volatile int lastMaxVisibleLines = 20 ;
/** 输入光标位置(0 = 最左端, inputText.length() = 最右端/末尾) */
private volatile int inputCursorPos = 0 ;
/** Ctrl+C 双击退出:上次按下时间 */
/** Ctrl+C 双击退出:上次按下时间 */
private volatile long lastCtrlCTime = 0 ;
private volatile long lastCtrlCTime = 0 ;
private static final long CTRL_C_EXIT_WINDOW_MS = 800 ; // 800ms内再按一次退出(匹配官方)
private static final long CTRL_C_EXIT_WINDOW_MS = 800 ; // 800ms内再按一次退出(匹配官方)
@ -181,17 +184,24 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
// 计算输入区行数
// 计算输入区行数
int inputLineCount = 1 ;
int inputLineCount = 1 ;
String lastLine = s . inputText ;
// 计算光标所在行的光标前文本(用于终端光标定位)
int cursorPos = Math . min ( inputCursorPos , s . inputText . length ( ) ) ;
String textBeforeCursor = s . inputText . substring ( 0 , cursorPos ) ;
String cursorLine ; // 光标所在行中,光标之前的文本
if ( snapAskOptions ! = null & & ! snapAskOptions . isEmpty ( ) & & snapHasCallback ) {
if ( snapAskOptions ! = null & & ! snapAskOptions . isEmpty ( ) & & snapHasCallback ) {
// AskUser 模式:选项数 + 提示行
inputLineCount = snapAskOptions . size ( ) + 1 ;
inputLineCount = snapAskOptions . size ( ) + 1 ;
cursorLine = s . inputText ;
} else if ( snapPermOptions ! = null & & ! snapPermOptions . isEmpty ( ) & & snapHasCallback ) {
} else if ( snapPermOptions ! = null & & ! snapPermOptions . isEmpty ( ) & & snapHasCallback ) {
// 权限选择模式:标题行 + 选项数 + 提示行
inputLineCount = snapPermOptions . size ( ) + 2 ;
inputLineCount = snapPermOptions . size ( ) + 2 ;
cursorLine = s . inputText ;
} else if ( ! s . inputText . isEmpty ( ) ) {
} else if ( ! s . inputText . isEmpty ( ) ) {
String [ ] inputLines = s . inputText . split ( "\n" , - 1 ) ;
String [ ] inputLines = s . inputText . split ( "\n" , - 1 ) ;
inputLineCount = inputLines . length ;
inputLineCount = inputLines . length ;
lastLine = inputLines [ inputLines . length - 1 ] ;
// 光标所在行:从光标前文本中提取最后一行
int lastNewline = textBeforeCursor . lastIndexOf ( '\n' ) ;
cursorLine = ( lastNewline > = 0 ) ? textBeforeCursor . substring ( lastNewline + 1 ) : textBeforeCursor ;
} else {
cursorLine = "" ;
}
}
// 光标定位:仅在需要文本输入时显示光标,选择模式和 agent 运行时隐藏
// 光标定位:仅在需要文本输入时显示光标,选择模式和 agent 运行时隐藏
@ -216,10 +226,9 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
int cursorCol = 1 + 20 + StringWidth . width ( historySearchQuery ) ;
int cursorCol = 1 + 20 + StringWidth . width ( historySearchQuery ) ;
setCursorPosition ( cursorRow , cursorCol ) ;
setCursorPosition ( cursorRow , cursorCol ) ;
} else {
} else {
// 正常输入模式:光标在输入文本末尾
// 正常输入模式:光标在 inputCursorPos 位置
int cursorRow = Math . max ( 0 , h - 3 ) ;
// 隐藏终端硬件光标(用渲染的 inverse 字符作为可见光标)
int cursorCol = 1 + PROMPT_WIDTH + StringWidth . width ( lastLine ) ;
setCursorPosition ( - 1 , - 1 ) ;
setCursorPosition ( cursorRow , cursorCol ) ;
}
}
int bottomHeight = 4 + inputLineCount ;
int bottomHeight = 4 + inputLineCount ;
@ -564,29 +573,43 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
Text content ;
Text content ;
if ( agentRunning . get ( ) ) {
if ( agentRunning . get ( ) ) {
// AI 运行中 — 输入区只显示提示符 + 块光标
content = Text . of ( "█" ) . color ( Color . BRIGHT_WHITE ) ;
content = Text . of ( "█" ) . color ( Color . BRIGHT_WHITE ) ;
} else if ( s . inputText . isEmpty ( ) ) {
} else if ( s . inputText . isEmpty ( ) ) {
// 空输入 — 块光标 + 占位提示
content = Text . of (
content = Text . of (
Text . of ( "█" ) . color ( Color . BRIGHT_WHITE ) ,
Text . of ( " " ) . inverse ( ) ,
Text . of ( " Type a message, / for commands" ) . dimmed ( )
Text . of ( " Type a message, / for commands" ) . dimmed ( )
) ;
) ;
} else {
} else {
// 有文字 — 文字 + 块光标 + ghost text(命令补全提示)
// 光标位置(clamp 防止越界)
int pos = Math . min ( inputCursorPos , s . inputText . length ( ) ) ;
String indent = " " . repeat ( PROMPT_WIDTH ) ;
String indent = " " . repeat ( PROMPT_WIDTH ) ;
if ( pos > = s . inputText . length ( ) ) {
// 光标在末尾 — 文字 + ghost text + 块光标
String displayText = s . inputText . replace ( "\n" , "\n" + indent ) ;
String displayText = s . inputText . replace ( "\n" , "\n" + indent ) ;
String ghost = getGhostText ( s . inputText ) ;
String ghost = getGhostText ( s . inputText ) ;
if ( ! ghost . isEmpty ( ) ) {
if ( ! ghost . isEmpty ( ) ) {
content = Text . of (
content = Text . of (
Text . of ( displayText ) . color ( Color . WHITE ) ,
Text . of ( displayText ) . color ( Color . WHITE ) ,
Text . of ( ghost ) . dimmed ( ) ,
Text . of ( ghost ) . dimmed ( ) ,
Text . of ( "█" ) . color ( Color . BRIGHT_WHITE )
Text . of ( " " ) . inverse ( )
) ;
) ;
} else {
} else {
content = Text . of (
content = Text . of (
Text . of ( displayText ) . color ( Color . WHITE ) ,
Text . of ( displayText ) . color ( Color . WHITE ) ,
Text . of ( "█" ) . color ( Color . BRIGHT_WHITE )
Text . of ( " " ) . inverse ( )
) ;
}
} else {
// 光标在中间 — [before] + [char under cursor inverted] + [after]
String before = s . inputText . substring ( 0 , pos ) ;
String cursorChar = String . valueOf ( s . inputText . charAt ( pos ) ) ;
String after = s . inputText . substring ( pos + 1 ) ;
String displayBefore = before . replace ( "\n" , "\n" + indent ) ;
String displayAfter = after . replace ( "\n" , "\n" + indent ) ;
content = Text . of (
Text . of ( displayBefore ) . color ( Color . WHITE ) ,
Text . of ( cursorChar ) . inverse ( ) ,
Text . of ( displayAfter ) . color ( Color . WHITE )
) ;
) ;
}
}
}
}
@ -747,6 +770,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} else {
} else {
// 第一次 Ctrl+C → 清空输入 + 提示再按一次退出
// 第一次 Ctrl+C → 清空输入 + 提示再按一次退出
lastCtrlCTime = now ;
lastCtrlCTime = now ;
inputCursorPos = 0 ;
setState ( new TuiState ( "" , s . messages , s . scrollOffset , false , "" ) ) ;
setState ( new TuiState ( "" , s . messages , s . scrollOffset , false , "" ) ) ;
// 启动定时器,超时后清除提示
// 启动定时器,超时后清除提示
Thread . startVirtualThread ( ( ) - > {
Thread . startVirtualThread ( ( ) - > {
@ -805,23 +829,67 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
}
}
if ( key . return_ ( ) & & key . meta ( ) ) {
if ( key . return_ ( ) & & key . meta ( ) ) {
// Shift+Enter: 多行换行
// Shift+Enter: 在光标位置插入换行
setState ( new TuiState ( s . inputText + "\n" , s . messages , 0 , false , "" ) ) ;
int pos = Math . min ( inputCursorPos , s . inputText . length ( ) ) ;
String newText = s . inputText . substring ( 0 , pos ) + "\n" + s . inputText . substring ( pos ) ;
inputCursorPos = pos + 1 ;
setState ( new TuiState ( newText , s . messages , 0 , false , "" ) ) ;
} else if ( key . tab ( ) & & ! key . shift ( ) ) {
} else if ( key . tab ( ) & & ! key . shift ( ) ) {
// Tab: 命令自动补全
// Tab: 命令自动补全
handleTabCompletion ( s ) ;
handleTabCompletion ( s ) ;
} else if ( key . return_ ( ) ) {
} else if ( key . return_ ( ) ) {
// Enter: 发送
// Enter: 发送
if ( ! s . inputText . isEmpty ( ) ) {
if ( ! s . inputText . isEmpty ( ) ) {
inputCursorPos = 0 ;
submitInput ( s . inputText , s ) ;
submitInput ( s . inputText , s ) ;
}
}
} else if ( key . backspace ( ) ) {
} else if ( key . backspace ( ) ) {
if ( ! s . inputText . isEmpty ( ) ) {
int pos = Math . min ( inputCursorPos , s . inputText . length ( ) ) ;
if ( pos > 0 ) {
abandonHistoryPreview ( ) ;
String newText = s . inputText . substring ( 0 , pos - 1 ) + s . inputText . substring ( pos ) ;
inputCursorPos = pos - 1 ;
updateCommandSuggestions ( newText ) ;
setState ( new TuiState ( newText , s . messages , s . scrollOffset , false , "" ) ) ;
}
} else if ( key . delete ( ) ) {
// Delete: 删除光标处字符
int pos = Math . min ( inputCursorPos , s . inputText . length ( ) ) ;
if ( pos < s . inputText . length ( ) ) {
abandonHistoryPreview ( ) ;
abandonHistoryPreview ( ) ;
String newText = s . inputText . substring ( 0 , s . inputText . length ( ) - 1 ) ;
String newText = s . inputText . substring ( 0 , pos ) + s . inputText . substring ( pos + 1 ) ;
updateCommandSuggestions ( newText ) ;
updateCommandSuggestions ( newText ) ;
setState ( new TuiState ( newText , s . messages , s . scrollOffset , false , "" ) ) ;
setState ( new TuiState ( newText , s . messages , s . scrollOffset , false , "" ) ) ;
}
}
} else if ( key . leftArrow ( ) ) {
// ← 左移光标
if ( inputCursorPos > 0 ) {
inputCursorPos - - ;
setState ( new TuiState ( s . inputText , s . messages , s . scrollOffset , s . thinking , s . thinkingText ) ) ;
}
} else if ( key . rightArrow ( ) ) {
// → 右移光标
if ( inputCursorPos < s . inputText . length ( ) ) {
inputCursorPos + + ;
setState ( new TuiState ( s . inputText , s . messages , s . scrollOffset , s . thinking , s . thinkingText ) ) ;
}
} else if ( key . home ( ) & & ! key . ctrl ( ) ) {
// Home: 光标移到行首
int pos = Math . min ( inputCursorPos , s . inputText . length ( ) ) ;
int lineStart = s . inputText . lastIndexOf ( '\n' , pos - 1 ) + 1 ;
if ( inputCursorPos ! = lineStart ) {
inputCursorPos = lineStart ;
setState ( new TuiState ( s . inputText , s . messages , s . scrollOffset , s . thinking , s . thinkingText ) ) ;
}
} else if ( key . end ( ) & & ! key . ctrl ( ) ) {
// End: 光标移到行尾
int pos = Math . min ( inputCursorPos , s . inputText . length ( ) ) ;
int lineEnd = s . inputText . indexOf ( '\n' , pos ) ;
if ( lineEnd < 0 ) lineEnd = s . inputText . length ( ) ;
if ( inputCursorPos ! = lineEnd ) {
inputCursorPos = lineEnd ;
setState ( new TuiState ( s . inputText , s . messages , s . scrollOffset , s . thinking , s . thinkingText ) ) ;
}
} else if ( key . upArrow ( ) ) {
} else if ( key . upArrow ( ) ) {
browseHistoryUp ( s ) ;
browseHistoryUp ( s ) ;
} else if ( key . downArrow ( ) ) {
} else if ( key . downArrow ( ) ) {
@ -842,11 +910,15 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
scroll ( s , - 10 ) ;
scroll ( s , - 10 ) ;
} else if ( key . escape ( ) ) {
} else if ( key . escape ( ) ) {
// Esc: 清空输入
// Esc: 清空输入
inputCursorPos = 0 ;
updateCommandSuggestions ( "" ) ;
updateCommandSuggestions ( "" ) ;
setState ( new TuiState ( "" , s . messages , s . scrollOffset , false , "" ) ) ;
setState ( new TuiState ( "" , s . messages , s . scrollOffset , false , "" ) ) ;
} else if ( ! input . isEmpty ( ) & & isPrintableInput ( input , key ) ) {
} else if ( ! input . isEmpty ( ) & & isPrintableInput ( input , key ) ) {
// 在光标位置插入文本
abandonHistoryPreview ( ) ;
abandonHistoryPreview ( ) ;
String newText = s . inputText + input ;
int pos = Math . min ( inputCursorPos , s . inputText . length ( ) ) ;
String newText = s . inputText . substring ( 0 , pos ) + input + s . inputText . substring ( pos ) ;
inputCursorPos = pos + input . length ( ) ;
updateCommandSuggestions ( newText ) ;
updateCommandSuggestions ( newText ) ;
setState ( new TuiState ( newText , s . messages , s . scrollOffset , false , "" ) ) ;
setState ( new TuiState ( newText , s . messages , s . scrollOffset , false , "" ) ) ;
}
}
@ -859,7 +931,10 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
if ( agentRunning . get ( ) | | text = = null | | text . isEmpty ( ) ) return ;
if ( agentRunning . get ( ) | | text = = null | | text . isEmpty ( ) ) return ;
TuiState s = getState ( ) ;
TuiState s = getState ( ) ;
abandonHistoryPreview ( ) ;
abandonHistoryPreview ( ) ;
setState ( new TuiState ( s . inputText + text , s . messages , s . scrollOffset , false , "" ) ) ;
int pos = Math . min ( inputCursorPos , s . inputText . length ( ) ) ;
String newText = s . inputText . substring ( 0 , pos ) + text + s . inputText . substring ( pos ) ;
inputCursorPos = pos + text . length ( ) ;
setState ( new TuiState ( newText , s . messages , s . scrollOffset , false , "" ) ) ;
}
}
}
}
@ -1076,14 +1151,14 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
}
}
if ( suggestions . size ( ) = = 1 ) {
if ( suggestions . size ( ) = = 1 ) {
// 唯一匹配 → 直接补全 + 空格(准备输入参数)
String completed = "/" + suggestions . getFirst ( ) ;
String completed = "/" + suggestions . getFirst ( ) ;
inputCursorPos = completed . length ( ) ;
updateCommandSuggestions ( completed ) ;
updateCommandSuggestions ( completed ) ;
setState ( new TuiState ( completed , s . messages , s . scrollOffset , false , "" ) ) ;
setState ( new TuiState ( completed , s . messages , s . scrollOffset , false , "" ) ) ;
} else {
} else {
// 多个匹配 → 循环选择
tabCompletionIndex = ( tabCompletionIndex + 1 ) % suggestions . size ( ) ;
tabCompletionIndex = ( tabCompletionIndex + 1 ) % suggestions . size ( ) ;
String completed = "/" + suggestions . get ( tabCompletionIndex ) ;
String completed = "/" + suggestions . get ( tabCompletionIndex ) ;
inputCursorPos = completed . length ( ) ;
setState ( new TuiState ( completed , s . messages , s . scrollOffset , false , "" ) ) ;
setState ( new TuiState ( completed , s . messages , s . scrollOffset , false , "" ) ) ;
}
}
}
}
@ -1383,7 +1458,9 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} else if ( historyIndex > 0 ) {
} else if ( historyIndex > 0 ) {
historyIndex - - ;
historyIndex - - ;
}
}
setState ( new TuiState ( inputHistory . get ( historyIndex ) , s . messages , s . scrollOffset , false , "" ) ) ;
String historyText = inputHistory . get ( historyIndex ) ;
inputCursorPos = historyText . length ( ) ;
setState ( new TuiState ( historyText , s . messages , s . scrollOffset , false , "" ) ) ;
}
}
private void browseHistoryDown ( TuiState s ) {
private void browseHistoryDown ( TuiState s ) {
@ -1391,11 +1468,14 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
historyIndex + + ;
historyIndex + + ;
if ( historyIndex > = inputHistory . size ( ) ) {
if ( historyIndex > = inputHistory . size ( ) ) {
historyIndex = - 1 ;
historyIndex = - 1 ;
inputCursorPos = savedInput . length ( ) ;
setState ( new TuiState ( savedInput , s . messages , s . scrollOffset , false , "" ) ) ;
setState ( new TuiState ( savedInput , s . messages , s . scrollOffset , false , "" ) ) ;
savedInput = "" ;
savedInput = "" ;
return ;
return ;
}
}
setState ( new TuiState ( inputHistory . get ( historyIndex ) , s . messages , s . scrollOffset , false , "" ) ) ;
String historyText = inputHistory . get ( historyIndex ) ;
inputCursorPos = historyText . length ( ) ;
setState ( new TuiState ( historyText , s . messages , s . scrollOffset , false , "" ) ) ;
}
}
private void abandonHistoryPreview ( ) {
private void abandonHistoryPreview ( ) {