1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
//! Cursor and Input Helpers for Chat View
//!
//! Contains cursor movement, clipboard operations, and input height calculation.
use tui_input::InputRequest;
use super::hints::detect_verb_in_input;
use super::{ChatMode, ChatView, CurrentVerb, VerbColor};
impl ChatView {
// ═══════════════════════════════════════════════════════════════════════════
// Cursor Movement and Input Helpers
// ═══════════════════════════════════════════════════════════════════════════
/// Insert character at cursor (delegates to tui-input)
pub fn insert_char(&mut self, c: char) {
self.input.handle(InputRequest::InsertChar(c));
// Track mutation in edit history (coalesces rapid keystrokes)
self.edit_history
.push(self.input.value(), self.input.cursor());
self.update_mode_from_input();
self.check_mention_trigger();
}
/// Delete character before cursor (delegates to tui-input)
pub fn backspace(&mut self) {
self.input.handle(InputRequest::DeletePrevChar);
self.edit_history
.push(self.input.value(), self.input.cursor());
self.update_mode_from_input();
self.check_mention_trigger();
}
/// Update chat_mode and current_verb based on input prefix
/// This syncs the mode indicator with what the user is typing
pub(super) fn update_mode_from_input(&mut self) {
let input = self.input.value();
// Check for verb prefix in input
if let Some((_, verb_color, is_complete, _)) = detect_verb_in_input(input) {
// Only update if we have a complete verb (with space after)
if is_complete {
// Update chat_mode for Infer/Agent toggle
match verb_color {
VerbColor::Agent => self.chat_mode = ChatMode::Agent,
VerbColor::Infer => self.chat_mode = ChatMode::Infer,
// Other verbs keep current chat_mode but update current_verb
_ => {}
}
// Update current_verb for MissionControlPanel
self.current_verb = match verb_color {
VerbColor::Infer => CurrentVerb::Infer,
VerbColor::Exec => CurrentVerb::Exec,
VerbColor::Fetch => CurrentVerb::Fetch,
VerbColor::Invoke => CurrentVerb::Invoke,
VerbColor::Agent => CurrentVerb::Agent,
VerbColor::Spawn => CurrentVerb::Spawn,
VerbColor::User => CurrentVerb::None, // User is not a verb command
};
}
} else if input.is_empty() || !input.starts_with('/') {
// No verb prefix → reset to defaults
self.current_verb = CurrentVerb::None;
// Keep chat_mode as-is when no prefix (user might want to stay in Agent mode)
}
}
/// Move cursor left (delegates to tui-input)
pub fn cursor_left(&mut self) {
self.input.handle(InputRequest::GoToPrevChar);
}
/// Move cursor right (delegates to tui-input)
pub fn cursor_right(&mut self) {
self.input.handle(InputRequest::GoToNextChar);
}
/// Move cursor to previous word (Ctrl+Left)
pub fn cursor_prev_word(&mut self) {
self.input.handle(InputRequest::GoToPrevWord);
}
/// Move cursor to next word (Ctrl+Right)
pub fn cursor_next_word(&mut self) {
self.input.handle(InputRequest::GoToNextWord);
}
/// Delete previous word (Ctrl+Backspace)
pub fn delete_prev_word(&mut self) {
// Force checkpoint before word deletion (significant edit)
self.edit_history
.checkpoint(self.input.value(), self.input.cursor());
self.input.handle(InputRequest::DeletePrevWord);
self.edit_history
.push(self.input.value(), self.input.cursor());
self.update_mode_from_input();
}
/// Go to start of input (Home)
pub fn cursor_start(&mut self) {
self.input.handle(InputRequest::GoToStart);
}
/// Go to end of input (End)
pub fn cursor_end(&mut self) {
self.input.handle(InputRequest::GoToEnd);
}
/// Copy input to clipboard (Ctrl+C)
pub fn copy_to_clipboard(&mut self) {
if let Some(clipboard) = &mut self.clipboard {
let _ = clipboard.set_text(self.input.value().to_string());
}
}
/// Paste from clipboard (Ctrl+V)
pub fn paste_from_clipboard(&mut self) {
if let Some(clipboard) = &mut self.clipboard {
if let Ok(text) = clipboard.get_text() {
// Checkpoint before paste (significant edit)
self.edit_history
.checkpoint(self.input.value(), self.input.cursor());
for c in text.chars() {
self.input.handle(InputRequest::InsertChar(c));
}
self.edit_history
.push(self.input.value(), self.input.cursor());
self.update_mode_from_input();
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Dynamic Input Height Helpers
// ═══════════════════════════════════════════════════════════════════════════
/// Calculate how many lines the input content requires with word wrapping
pub(super) fn calculate_input_lines(&self, available_width: u16) -> usize {
let input_value = self.input.value();
if input_value.is_empty() {
return 1;
}
// Account for prefix (e.g., "🦋 nika > ") which takes ~12 chars
let prefix_width = 14_u16;
let content_width = available_width.saturating_sub(prefix_width + 2); // +2 for borders
if content_width == 0 {
return 1;
}
let mut total_lines = 0;
for line in input_value.split('\n') {
if line.is_empty() {
total_lines += 1;
} else {
// Calculate wrapped lines for this physical line
let char_count = line.chars().count();
let lines_needed = (char_count as u16).div_ceil(content_width);
total_lines += lines_needed.max(1) as usize;
}
}
total_lines.max(1)
}
/// Ensure the cursor line is visible by adjusting scroll offset
pub(super) fn ensure_input_cursor_visible(&mut self, cursor_line: usize, total_lines: usize) {
if total_lines <= self.input_max_lines {
self.input_scroll_offset = 0;
return;
}
// Keep a 1-line margin at top/bottom when scrolling
let margin = 1;
// If cursor is below visible area, scroll down
if cursor_line >= self.input_scroll_offset + self.input_max_lines - margin {
self.input_scroll_offset =
cursor_line.saturating_sub(self.input_max_lines - 1 - margin);
}
// If cursor is above visible area, scroll up
if cursor_line < self.input_scroll_offset + margin {
self.input_scroll_offset = cursor_line.saturating_sub(margin);
}
// Clamp scroll offset to valid range
let max_offset = total_lines.saturating_sub(self.input_max_lines);
self.input_scroll_offset = self.input_scroll_offset.min(max_offset);
}
/// Calculate the dynamic input height for layout
pub(super) fn calculate_input_height(&self, available_width: u16) -> u16 {
let content_lines = self.calculate_input_lines(available_width);
let clamped_lines = content_lines.clamp(1, self.input_max_lines);
(clamped_lines as u16) + 2 // Add 2 for borders
}
/// Get the line number where the cursor is (for scroll tracking)
pub(super) fn get_cursor_line(&self, available_width: u16) -> usize {
let input_value = self.input.value();
let cursor_pos = self.input.cursor();
if input_value.is_empty() {
return 0;
}
let prefix_width = 14_u16;
let content_width = available_width.saturating_sub(prefix_width + 2);
if content_width == 0 {
return 0;
}
let mut current_line = 0;
let mut char_index = 0;
for line in input_value.split('\n') {
let line_len = line.chars().count();
if char_index + line_len >= cursor_pos {
// Cursor is in this line - calculate wrapped position
let pos_in_line = cursor_pos - char_index;
let wrapped_line_offset = pos_in_line / content_width as usize;
return current_line + wrapped_line_offset;
}
// Add wrapped lines for this physical line
let lines_for_this = if line.is_empty() {
1
} else {
((line_len as u16).div_ceil(content_width)).max(1) as usize
};
current_line += lines_for_this;
char_index += line_len + 1; // +1 for newline
}
current_line
}
}