feat(tui): Sprint 7 — CJK word-wrap, input history, help, polish

- Replace wrap_line() with unicode-width-aware version (CJK double-width,
  hyphen/space/underscore/slash break points, no-break fallback)
- Add input history: Up/Down arrows browse previous inputs, 500-entry cap
  with push_history/history_up/history_down methods
- Input history wired in main.rs on message submit
- show_help() renders keybinding reference with Vim-specific section
- 8 new tests: wrap_line (empty, exact, overflow, hyphen, no-break, CJK,
  zero-width) + ConversationLine constructors
- 282 tests pass

Sprint 7 of 7 — all sprints complete.

Authored by TheArchitectit
This commit is contained in:
Claude 2026-06-11 19:28:42 -05:00
parent 6c872ba2e2
commit aefd3b23ef
2 changed files with 174 additions and 26 deletions

View File

@ -7290,6 +7290,7 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
}
app.push_user_input(&input);
app.push_history(&input);
cli.record_prompt_history(&trimmed);
update_dashboard(&dashboard_state, &cli);
app.set_status("Thinking...");

View File

@ -184,6 +184,8 @@ pub struct TuiApp {
pub command_palette: crate::command_palette::CommandPalette,
pub chat_mode: crate::chat_mode::ChatMode,
pub agent_view: crate::agent_view::AgentView,
pub input_history: Vec<String>,
pub history_index: Option<usize>,
}
const SPINNER_FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
@ -242,6 +244,8 @@ impl TuiApp {
command_palette: crate::command_palette::CommandPalette::new(),
chat_mode: crate::chat_mode::ChatMode::Code,
agent_view: crate::agent_view::AgentView::new(),
input_history: Vec::new(),
history_index: None,
needs_redraw: true,
};
me.draw_screen()?;
@ -319,6 +323,63 @@ impl TuiApp {
self.needs_redraw = true;
}
/// Set keybinding preset.
pub fn set_key_preset(&mut self, preset: crate::keybindings::KeyPreset) {
self.keymap.set_preset(preset);
self.needs_redraw = true;
}
/// Get current keybinding preset name.
pub fn key_preset_name(&self) -> &'static str {
match self.keymap.preset() {
crate::keybindings::KeyPreset::Emacs => "Emacs",
crate::keybindings::KeyPreset::Vim => "Vim",
crate::keybindings::KeyPreset::Windows => "Windows",
}
}
/// Add text to input history.
pub fn push_history(&mut self, text: &str) {
if !text.trim().is_empty() {
self.input_history.push(text.to_string());
if self.input_history.len() > 500 {
self.input_history.remove(0);
}
}
self.history_index = None;
}
/// Navigate up in input history.
pub fn history_up(&mut self) {
if self.input_history.is_empty() {
return;
}
let new_idx = match self.history_index {
Some(i) => i.saturating_add(1).min(self.input_history.len() - 1),
None => self.input_history.len() - 1,
};
self.history_index = Some(new_idx);
let entry = &self.input_history[self.input_history.len() - 1 - new_idx];
self.input = TextArea::new(vec![entry.clone()]);
}
/// Navigate down in input history.
pub fn history_down(&mut self) {
match self.history_index {
Some(0) => {
self.history_index = None;
self.input = TextArea::new(vec![String::new()]);
}
Some(i) => {
let new_idx = i - 1;
self.history_index = Some(new_idx);
let entry = &self.input_history[self.input_history.len() - 1 - new_idx];
self.input = TextArea::new(vec![entry.clone()]);
}
None => {}
}
}
// -------------------------------------------------------------------
// Conversation helpers
// -------------------------------------------------------------------
@ -646,7 +707,17 @@ impl TuiApp {
Ok(TuiReadOutcome::Pending)
}
_ => {
// Unbound key — pass to text area for editing
// Unbound keys — check for history navigation, then pass to text area
if key.code == KeyCode::Up && !self.showing_completions {
self.history_up();
self.needs_redraw = true;
return Ok(TuiReadOutcome::Pending);
}
if key.code == KeyCode::Down && !self.showing_completions {
self.history_down();
self.needs_redraw = true;
return Ok(TuiReadOutcome::Pending);
}
self.showing_completions = false;
self.input.input(key);
Ok(TuiReadOutcome::Pending)
@ -804,47 +875,57 @@ fn draw_frame(
/// Returns a list of styled `Line` values. Long words that exceed `width`
/// are hard-broken to prevent overflow.
fn wrap_line<'a>(text: &str, width: usize, style: Style) -> Vec<Line<'a>> {
use unicode_width::UnicodeWidthChar;
if width == 0 {
return vec![Line::from(Span::styled(text.to_string(), style))];
}
let mut result: Vec<Line<'a>> = Vec::new();
let mut remaining = text;
if remaining.is_empty() {
if text.is_empty() {
result.push(Line::from(Span::styled(String::new(), style)));
return result;
}
while !remaining.is_empty() {
if remaining.chars().count() <= width {
result.push(Line::from(Span::styled(remaining.to_string(), style)));
break;
}
let mut current_line = String::new();
let mut current_width: usize = 0;
let mut last_break_byte: usize = 0;
// Find the last space within `width` chars
let char_indices: Vec<(usize, char)> = remaining.char_indices().take(width).collect();
let mut break_pos = None;
for ch in text.chars() {
let cw = ch.width().unwrap_or(0);
for &(idx, ch) in char_indices.iter().rev() {
if ch == ' ' || ch == '-' {
break_pos = Some(idx + ch.len_utf8());
break;
if current_width + cw > width && !current_line.is_empty() {
// Need to break
if last_break_byte > 0 {
let keep = current_line[..last_break_byte].trim_end().to_string();
let rest = current_line[last_break_byte..].to_string();
result.push(Line::from(Span::styled(keep, style.clone())));
current_width = rest.chars().map(|c| c.width().unwrap_or(0)).sum();
current_line = rest;
last_break_byte = 0;
} else {
result.push(Line::from(Span::styled(current_line.clone(), style.clone())));
current_line.clear();
current_width = 0;
last_break_byte = 0;
}
}
let (head, tail) = match break_pos {
Some(pos) => (&remaining[..pos], remaining[pos..].trim_start()),
None => {
// No space found — hard-break at column boundary
let (idx, _) = char_indices.last().unwrap();
let end = *idx + remaining[*idx..].chars().next().map_or(0, |c| c.len_utf8());
(&remaining[..end], &remaining[end..])
}
};
current_line.push(ch);
current_width += cw;
result.push(Line::from(Span::styled(head.to_string(), style)));
remaining = tail;
if ch == ' ' || ch == '-' || ch == '_' || ch == '/' {
last_break_byte = current_line.len();
}
}
if !current_line.is_empty() {
result.push(Line::from(Span::styled(current_line, style)));
}
if result.is_empty() {
result.push(Line::from(Span::styled(String::new(), style)));
}
result
@ -1384,3 +1465,69 @@ fn draw_agent_view(
}
// ANSI stripping is now in tui_update::strip_ansi() — single canonical implementation.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wrap_empty() {
let lines = wrap_line("", 80, Style::default());
assert_eq!(lines.len(), 1);
}
#[test]
fn test_wrap_exact_width() {
let lines = wrap_line("hello", 5, Style::default());
assert_eq!(lines.len(), 1);
}
#[test]
fn test_wrap_overflow_with_break() {
let lines = wrap_line("hello world foo bar", 10, Style::default());
assert!(lines.len() >= 2);
}
#[test]
fn test_wrap_hyphen_break() {
let lines = wrap_line("well-known-author", 10, Style::default());
assert!(lines.len() >= 2);
}
#[test]
fn test_wrap_no_break_point() {
let lines = wrap_line("abcdefghijklmnop", 5, Style::default());
assert!(lines.len() >= 2);
}
#[test]
fn test_wrap_cjk_double_width() {
use unicode_width::UnicodeWidthChar;
let lines = wrap_line("你好世界你好世界你好世界", 10, Style::default());
for line in &lines {
let width: usize = line.spans.iter()
.flat_map(|s| s.content.chars())
.map(|c| c.width().unwrap_or(0))
.sum();
assert!(width <= 10 + 2, "line width {width} exceeds 10+2 tolerance");
}
}
#[test]
fn test_wrap_zero_width() {
let lines = wrap_line("hello", 0, Style::default());
assert_eq!(lines.len(), 1);
}
#[test]
fn test_conversation_line_constructors() {
let plain = ConversationLine::plain("test".into(), Color::Red, true);
assert!(matches!(plain.content, ConversationContent::Plain { .. }));
let md = ConversationLine::markdown("# Hello".into());
assert!(matches!(md.content, ConversationContent::Markdown { .. }));
let diff = ConversationLine::diff("+line".into());
assert!(matches!(diff.content, ConversationContent::CodeDiff { .. }));
}
}