diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index 3c4e839..fb49d6c 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -199,7 +199,10 @@ impl Default for SearchState { /// start a fresh conversation. #[derive(Default)] pub struct AskState { - pub input: String, + /// p9-fb-10: `InputBuffer` tracks display-column cursor position + /// alongside content so wide chars (Hangul, CJK) place the + /// terminal cursor in the correct column. + pub input: crate::input::InputBuffer, /// Toggled by the `e` key. Re-applied on the next `Enter`. pub explain: bool, /// True between `Enter` press and worker thread completion. diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs index 41fc7f5..e59f961 100644 --- a/crates/kebab-tui/src/ask.rs +++ b/crates/kebab-tui/src/ask.rs @@ -73,6 +73,25 @@ fn render_input(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::T .title("ask (Enter=submit e=explain Ctrl-L=new conversation Esc=back)") .borders(Borders::ALL); f.render_widget(Paragraph::new(line).block(block), area); + + // p9-fb-10: position the terminal cursor at the end of the typed + // input. `area` has Borders::ALL so the inner content row is at + // y = area.y + 1, x starts at area.x + 1. The "? " prefix is + // 2 display columns; the buffer tracks cursor_col in the same + // column units. Clamp at the right inner edge so an overlong + // input never pushes the cursor outside the box. + // ratatui calls show_cursor + MoveTo whenever cursor_position is + // Some (our case here). When a render fn omits set_cursor_position + // (Library/Inspect), ratatui calls hide_cursor instead. So this + // single call both positions and unhides the caret for the Ask + // input column. + let prompt_w: u16 = 2; // "? " = 2 display columns + let inner_x = area.x + 1; + let inner_y = area.y + 1; + let inner_right = area.x + area.width.saturating_sub(1); + let raw_x = inner_x + prompt_w + s.input.cursor_col() as u16; + let cursor_x = raw_x.min(inner_right.saturating_sub(1)); + f.set_cursor_position((cursor_x, inner_y)); } fn render_answer(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) { @@ -333,7 +352,7 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome { if state .ask .as_ref() - .map(|s| s.streaming || s.thread.is_some() || s.input.trim().is_empty()) + .map(|s| s.streaming || s.thread.is_some() || s.input.as_str().trim().is_empty()) .unwrap_or(true) { return KeyOutcome::Continue; @@ -363,7 +382,7 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome { } (KeyCode::Backspace, _) => { let s = state.ask.as_mut().unwrap(); - s.input.pop(); + s.input.pop_char(); KeyOutcome::Continue } // Insert mode: every non-chord Char (incl. e/j/k) types into @@ -374,7 +393,7 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome { && !m.contains(KeyModifiers::ALT) => { let s = state.ask.as_mut().unwrap(); - s.input.push(c); + s.input.push_char(c); KeyOutcome::Continue } // Normal mode + un-handled Char → no-op (no typing in Normal). @@ -386,7 +405,9 @@ fn spawn_ask_worker(state: &mut App) { let (tx, rx) = mpsc::channel::(); let cfg = state.config.clone(); let s = state.ask.as_mut().unwrap(); - let query = s.input.clone(); + // p9-fb-10: take() consumes the input in one step (no clone + + // clear). The buffer is left empty with cursor at 0. + let query = s.input.take(); let explain = s.explain; s.partial.clear(); s.last_answer = None; @@ -397,7 +418,6 @@ fn spawn_ask_worker(state: &mut App) { // clear the input box, ensure conversation_id exists, snapshot // history for the worker. s.current_question = Some(query.clone()); - s.input.clear(); if s.conversation_id.is_none() { s.conversation_id = Some(make_conversation_id()); } diff --git a/crates/kebab-tui/src/input.rs b/crates/kebab-tui/src/input.rs index 74c95f4..2e52ce1 100644 --- a/crates/kebab-tui/src/input.rs +++ b/crates/kebab-tui/src/input.rs @@ -128,6 +128,13 @@ impl InputBuffer { self.cursor_col = 0; } + /// Move the typed string out, leaving the buffer empty (cursor 0). + /// Convenience for "submit" flows that consume the input. + pub fn take(&mut self) -> String { + self.cursor_col = 0; + std::mem::take(&mut self.content) + } + /// Borrow the typed text. pub fn as_str(&self) -> &str { &self.content @@ -299,4 +306,15 @@ mod tests { assert!(b.pop_char().is_none()); assert_eq!(b.cursor_col(), 0); } + + /// p9-fb-10: take() returns the content and resets state. + #[test] + fn input_buffer_take_returns_content_and_resets() { + let mut b = InputBuffer::new(); + b.push_str("러스트"); + let s = b.take(); + assert_eq!(s, "러스트"); + assert!(b.is_empty()); + assert_eq!(b.cursor_col(), 0); + } } diff --git a/crates/kebab-tui/tests/ask.rs b/crates/kebab-tui/tests/ask.rs index 8929710..b456cf0 100644 --- a/crates/kebab-tui/tests/ask.rs +++ b/crates/kebab-tui/tests/ask.rs @@ -104,20 +104,20 @@ fn typing_appends_to_input() { KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), ); } - assert_eq!(app.ask.as_ref().unwrap().input, "hello"); + assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "hello"); } #[test] fn backspace_pops_input() { let mut app = fresh_app(); { - app.ask.as_mut().unwrap().input = "abcd".into(); + app.ask.as_mut().unwrap().input.push_str("abcd"); } handle_key_ask( &mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); - assert_eq!(app.ask.as_ref().unwrap().input, "abc"); + assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "abc"); } /// p9-fb-12 follow-up: `e` types into input in Insert mode (does @@ -135,7 +135,7 @@ fn e_types_in_insert_mode_does_not_toggle_explain() { KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); let s = app.ask.as_ref().unwrap(); - assert_eq!(s.input, "e", "e must type in Insert mode"); + assert_eq!(s.input.as_str(), "e", "e must type in Insert mode"); assert!(!s.explain, "explain must NOT toggle in Insert mode"); } @@ -165,7 +165,7 @@ fn jk_scroll_in_normal_mode_type_in_insert() { &mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), ); - assert_eq!(app.ask.as_ref().unwrap().input, "jk"); + assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "jk"); assert_eq!(app.ask.as_ref().unwrap().scroll, 0, "no scroll in Insert"); } @@ -193,14 +193,14 @@ fn e_toggles_explain_in_normal_mode() { fn e_typed_into_input_when_input_nonempty() { let mut app = fresh_app(); { - app.ask.as_mut().unwrap().input = "qu".into(); + app.ask.as_mut().unwrap().input.push_str("qu"); } handle_key_ask( &mut app, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); let s = app.ask.as_ref().unwrap(); - assert_eq!(s.input, "que"); + assert_eq!(s.input.as_str(), "que"); assert!(!s.explain, "explain must NOT toggle while typing a word"); } @@ -220,7 +220,7 @@ fn enter_while_streaming_is_noop() { let mut app = fresh_app(); { let s = app.ask.as_mut().unwrap(); - s.input = "anything".into(); + s.input.push_str("anything"); s.streaming = true; } handle_key_ask( @@ -265,7 +265,7 @@ fn render_streaming_shows_partial_with_cursor() { let mut app = fresh_app(); { let s = app.ask.as_mut().unwrap(); - s.input = "what is RRF fusion?".into(); + s.input.push_str("what is RRF fusion?"); s.streaming = true; s.partial = "RRF는 reciprocal rank fusion".into(); } @@ -299,7 +299,7 @@ fn render_grounded_answer_with_citation() { let mut app = fresh_app(); { let s = app.ask.as_mut().unwrap(); - s.input = "test".into(); + s.input.push_str("test"); let ans = make_answer(true, None, "test answer body [1]."); // p9-fb-16: transcript renders completed turns; populate one // alongside last_answer so the right-panel status + body @@ -413,7 +413,7 @@ fn enter_with_detached_prior_thread_is_blocked() { let mut app = fresh_app(); { let s = app.ask.as_mut().unwrap(); - s.input = "another question".into(); + s.input.push_str("another question"); s.streaming = false; // Simulate a detached prior worker by hand-installing a // never-ending JoinHandle. (We can't easily make a sleeping @@ -630,3 +630,27 @@ fn render_streaming_inflight_turn_appears_below_completed_turns() { "completed turn before in-flight; got: {answered_pos} vs {partial_pos}" ); } + +/// p9-fb-10: typing Hangul into Ask input advances cursor by 2 +/// per char and round-trips through the buffer correctly. +#[test] +fn hangul_typing_in_ask_input_advances_cursor_by_two_per_char() { + let mut app = fresh_app(); + // Switch to ask + INSERT mode so chars type as input. + app.focus = Pane::Ask; + app.mode = kebab_tui::Mode::auto_for(Pane::Ask); + for ch in "한글".chars() { + kebab_tui::handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "한글"); + assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 4); + kebab_tui::handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "한"); + assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 2); +}