feat(kebab-tui): AskState.input → InputBuffer + take() helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 09:44:10 +00:00
parent 997fe46956
commit 7784e14a5b
4 changed files with 82 additions and 17 deletions

View File

@@ -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.

View File

@@ -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());
}

View File

@@ -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);
}
}

View File

@@ -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);
}