diff --git a/core/kptui/src/main.rs b/core/kptui/src/main.rs index a9e424e..9168764 100644 --- a/core/kptui/src/main.rs +++ b/core/kptui/src/main.rs @@ -113,13 +113,22 @@ fn view_mode(width: u16, has_active_conversation: bool, requested: ViewMode) -> fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) { let area = frame.area(); - let mode = view_mode(area.width, app.active_conversation_id.is_some(), requested_view); + let mode = view_mode( + area.width, + app.active_conversation_id.is_some(), + requested_view, + ); - let show_input = matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some(); + let show_input = + matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some(); let chunks = if show_input { Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(3), Constraint::Length(1)]) + .constraints([ + Constraint::Min(1), + Constraint::Length(3), + Constraint::Length(1), + ]) .split(area) } else { Layout::default() @@ -149,12 +158,13 @@ fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) { } if let Some(input_area) = input_area { - render_input(frame, app, input_area); + let input_scroll_x = render_input(frame, app, input_area); if app.focus == Focus::Input { + let cursor_col = visual_width_u16(app.input.as_str()); let mut x = input_area .x .saturating_add(1) - .saturating_add(app.input.len() as u16); + .saturating_add(cursor_col.saturating_sub(input_scroll_x)); let max_x = input_area .x .saturating_add(input_area.width.saturating_sub(2)); @@ -184,7 +194,10 @@ fn render_conversations(frame: &mut Frame, app: &AppState, area: Rect, in_split: String::new() }; let header = Line::from(vec![ - Span::styled(c.title.clone(), Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + c.title.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), Span::raw(unread), ]); let preview = Line::from(Span::styled( @@ -234,7 +247,10 @@ fn render_transcript(frame: &mut Frame, app: &AppState, area: Rect, in_split: bo .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()); lines.push(Line::from(vec![ - Span::styled(message.sender.clone(), Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + message.sender.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), Span::raw(" "), Span::styled(ts, Style::default().fg(Color::DarkGray)), ])); @@ -268,23 +284,27 @@ fn render_transcript(frame: &mut Frame, app: &AppState, area: Rect, in_split: bo frame.render_widget(paragraph, area); } -fn render_input(frame: &mut Frame, app: &AppState, area: Rect) { +fn render_input(frame: &mut Frame, app: &AppState, area: Rect) -> u16 { let title = if app.focus == Focus::Input { "Reply (Enter to send)" } else { "Reply (press i to type)" }; + + let inner_width = area.width.saturating_sub(2).max(1); + let cursor_col = visual_width_u16(app.input.as_str()); + let input_scroll_x = cursor_col.saturating_sub(inner_width.saturating_sub(1)); + let input = Paragraph::new(app.input.as_str()) - .block(Block::default().borders(Borders::ALL).title(title)); + .block(Block::default().borders(Borders::ALL).title(title)) + .scroll((0, input_scroll_x)); frame.render_widget(input, area); + input_scroll_x } fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode) { let mut parts = vec![ - format!( - "{} convs", - app.conversations.len() - ), + format!("{} convs", app.conversations.len()), match mode { ViewMode::Split => "split".to_string(), ViewMode::List => "list".to_string(), @@ -295,7 +315,10 @@ fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode) parts.push(app.status.clone()); } let line = parts.join(" | "); - frame.render_widget(Paragraph::new(line).block(Block::default().borders(Borders::TOP)), area); + frame.render_widget( + Paragraph::new(line).block(Block::default().borders(Borders::TOP)), + area, + ); } fn main() -> Result<()> { @@ -322,7 +345,9 @@ fn main() -> Result<()> { res } -fn run_app(terminal: &mut ratatui::Terminal>) -> Result<()> { +fn run_app( + terminal: &mut ratatui::Terminal>, +) -> Result<()> { let (request_tx, request_rx) = mpsc::channel::(); let (event_tx, event_rx) = mpsc::channel::(); let _worker = daemon::spawn_worker(request_rx, event_tx); @@ -361,14 +386,12 @@ fn run_app(terminal: &mut ratatui::Terminal u16 { - let mode = view_mode(size.width, app.active_conversation_id.is_some(), requested_view); + let mode = view_mode( + size.width, + app.active_conversation_id.is_some(), + requested_view, + ); let outer_width = match mode { ViewMode::Split => { let left_width = (size.width / 3).clamp(24, 40); @@ -666,10 +715,11 @@ fn visual_line_count(s: &str, inner_width: u16) -> u16 { ((w + iw - 1) / iw).min(u16::MAX as usize) as u16 } -fn transcript_content_visual_lines( - messages: &[daemon::ChatMessage], - inner_width: u16, -) -> u16 { +fn visual_width_u16(s: &str) -> u16 { + s.width().min(u16::MAX as usize) as u16 +} + +fn transcript_content_visual_lines(messages: &[daemon::ChatMessage], inner_width: u16) -> u16 { if messages.is_empty() { return 1; } @@ -699,7 +749,11 @@ fn transcript_content_visual_lines( } fn transcript_viewport_height(size: Size, app: &AppState, requested_view: ViewMode) -> u16 { - let mode = view_mode(size.width, app.active_conversation_id.is_some(), requested_view); + let mode = view_mode( + size.width, + app.active_conversation_id.is_some(), + requested_view, + ); let show_input = matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();