feat(kebab-tui): AskState.input → InputBuffer + take() helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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::<String>();
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user