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:
parent
6c872ba2e2
commit
aefd3b23ef
|
|
@ -7290,6 +7290,7 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
app.push_user_input(&input);
|
app.push_user_input(&input);
|
||||||
|
app.push_history(&input);
|
||||||
cli.record_prompt_history(&trimmed);
|
cli.record_prompt_history(&trimmed);
|
||||||
update_dashboard(&dashboard_state, &cli);
|
update_dashboard(&dashboard_state, &cli);
|
||||||
app.set_status("Thinking...");
|
app.set_status("Thinking...");
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,8 @@ pub struct TuiApp {
|
||||||
pub command_palette: crate::command_palette::CommandPalette,
|
pub command_palette: crate::command_palette::CommandPalette,
|
||||||
pub chat_mode: crate::chat_mode::ChatMode,
|
pub chat_mode: crate::chat_mode::ChatMode,
|
||||||
pub agent_view: crate::agent_view::AgentView,
|
pub agent_view: crate::agent_view::AgentView,
|
||||||
|
pub input_history: Vec<String>,
|
||||||
|
pub history_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
@ -242,6 +244,8 @@ impl TuiApp {
|
||||||
command_palette: crate::command_palette::CommandPalette::new(),
|
command_palette: crate::command_palette::CommandPalette::new(),
|
||||||
chat_mode: crate::chat_mode::ChatMode::Code,
|
chat_mode: crate::chat_mode::ChatMode::Code,
|
||||||
agent_view: crate::agent_view::AgentView::new(),
|
agent_view: crate::agent_view::AgentView::new(),
|
||||||
|
input_history: Vec::new(),
|
||||||
|
history_index: None,
|
||||||
needs_redraw: true,
|
needs_redraw: true,
|
||||||
};
|
};
|
||||||
me.draw_screen()?;
|
me.draw_screen()?;
|
||||||
|
|
@ -319,6 +323,63 @@ impl TuiApp {
|
||||||
self.needs_redraw = true;
|
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
|
// Conversation helpers
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
@ -646,7 +707,17 @@ impl TuiApp {
|
||||||
Ok(TuiReadOutcome::Pending)
|
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.showing_completions = false;
|
||||||
self.input.input(key);
|
self.input.input(key);
|
||||||
Ok(TuiReadOutcome::Pending)
|
Ok(TuiReadOutcome::Pending)
|
||||||
|
|
@ -804,47 +875,57 @@ fn draw_frame(
|
||||||
/// Returns a list of styled `Line` values. Long words that exceed `width`
|
/// Returns a list of styled `Line` values. Long words that exceed `width`
|
||||||
/// are hard-broken to prevent overflow.
|
/// are hard-broken to prevent overflow.
|
||||||
fn wrap_line<'a>(text: &str, width: usize, style: Style) -> Vec<Line<'a>> {
|
fn wrap_line<'a>(text: &str, width: usize, style: Style) -> Vec<Line<'a>> {
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
if width == 0 {
|
if width == 0 {
|
||||||
return vec![Line::from(Span::styled(text.to_string(), style))];
|
return vec![Line::from(Span::styled(text.to_string(), style))];
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut result: Vec<Line<'a>> = Vec::new();
|
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)));
|
result.push(Line::from(Span::styled(String::new(), style)));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
while !remaining.is_empty() {
|
let mut current_line = String::new();
|
||||||
if remaining.chars().count() <= width {
|
let mut current_width: usize = 0;
|
||||||
result.push(Line::from(Span::styled(remaining.to_string(), style)));
|
let mut last_break_byte: usize = 0;
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the last space within `width` chars
|
for ch in text.chars() {
|
||||||
let char_indices: Vec<(usize, char)> = remaining.char_indices().take(width).collect();
|
let cw = ch.width().unwrap_or(0);
|
||||||
let mut break_pos = None;
|
|
||||||
|
|
||||||
for &(idx, ch) in char_indices.iter().rev() {
|
if current_width + cw > width && !current_line.is_empty() {
|
||||||
if ch == ' ' || ch == '-' {
|
// Need to break
|
||||||
break_pos = Some(idx + ch.len_utf8());
|
if last_break_byte > 0 {
|
||||||
break;
|
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 {
|
current_line.push(ch);
|
||||||
Some(pos) => (&remaining[..pos], remaining[pos..].trim_start()),
|
current_width += cw;
|
||||||
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..])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
result.push(Line::from(Span::styled(head.to_string(), style)));
|
if ch == ' ' || ch == '-' || ch == '_' || ch == '/' {
|
||||||
remaining = tail;
|
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
|
result
|
||||||
|
|
@ -1384,3 +1465,69 @@ fn draw_agent_view(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ANSI stripping is now in tui_update::strip_ansi() — single canonical implementation.
|
// 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 { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue