1use std::io;
2use std::sync::{Arc, RwLock};
3use std::time::{Duration, Instant};
4
5use anyhow::Result;
6use codei_agent::{AgentError, AgentEvent, AgentLoop};
7use codei_commands::{filter_slash_hints, parse_input, Input, SlashCommand, SlashHint};
8use codei_config::ResolvedConfig;
9use codei_i18n::{t, t_fmt};
10use codei_llm::Usage;
11use codei_session::{Session, SessionStore};
12use codei_tools::{handler_for_policy, ApprovalPolicy, SharedApprovalGate, ToolContext};
13use crossterm::event::{
14 self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyModifiers,
15 KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
16};
17use crossterm::terminal::{
18 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
19};
20use crossterm::ExecutableCommand;
21use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect};
22use ratatui::style::{Color, Modifier, Style};
23use ratatui::text::{Line, Span, Text};
24use ratatui::widgets::{
25 Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
26 ScrollbarState, Wrap,
27};
28use ratatui::DefaultTerminal;
29use tokio::sync::{mpsc, Mutex};
30use tokio::task::JoinHandle;
31use unicode_width::UnicodeWidthStr;
32
33use crate::clipboard::copy_to_clipboard;
34use crate::launch::InteractiveLaunch;
35use crate::slash::{handle_slash, SlashAction, SlashContext};
36
37const INPUT_MIN_HEIGHT: u16 = 3;
38const INPUT_MAX_HEIGHT: u16 = 10;
39const INPUT_HISTORY_LIMIT: usize = 200;
40
41struct InputHistory {
42 entries: Vec<String>,
43 browse_index: Option<usize>,
44 draft: Option<String>,
45}
46
47impl InputHistory {
48 fn new() -> Self {
49 Self {
50 entries: Vec::new(),
51 browse_index: None,
52 draft: None,
53 }
54 }
55
56 fn push(&mut self, line: String) {
57 if line.trim().is_empty() {
58 return;
59 }
60 if self.entries.last() != Some(&line) {
61 self.entries.push(line);
62 if self.entries.len() > INPUT_HISTORY_LIMIT {
63 self.entries.remove(0);
64 }
65 }
66 self.browse_index = None;
67 self.draft = None;
68 }
69
70 fn browse_older(&mut self, current_input: &str) -> Option<String> {
71 if self.entries.is_empty() {
72 return None;
73 }
74 match self.browse_index {
75 None => {
76 self.draft = Some(current_input.to_string());
77 let idx = self.entries.len() - 1;
78 self.browse_index = Some(idx);
79 Some(self.entries[idx].clone())
80 }
81 Some(0) => None,
82 Some(i) => {
83 let idx = i - 1;
84 self.browse_index = Some(idx);
85 Some(self.entries[idx].clone())
86 }
87 }
88 }
89
90 fn browse_newer(&mut self) -> Option<String> {
91 let i = self.browse_index?;
92 if i + 1 < self.entries.len() {
93 let idx = i + 1;
94 self.browse_index = Some(idx);
95 Some(self.entries[idx].clone())
96 } else {
97 self.browse_index = None;
98 Some(self.draft.take().unwrap_or_default())
99 }
100 }
101
102 fn clear_browse(&mut self) {
103 self.browse_index = None;
104 self.draft = None;
105 }
106}
107
108struct ChatLine {
109 text: String,
110 style: Style,
111}
112
113pub struct TuiOptions {
114 pub auto_approve: bool,
115}
116
117pub async fn run_tui(launch: InteractiveLaunch, opts: TuiOptions) -> Result<()> {
118 let InteractiveLaunch {
119 config,
120 provider,
121 provider_name,
122 model,
123 session,
124 store,
125 mcp,
126 } = launch;
127 let approval_gate = Arc::new(SharedApprovalGate::new());
128 let approval: Arc<dyn codei_tools::ApprovalHandler> = if opts.auto_approve {
129 Arc::from(handler_for_policy(ApprovalPolicy::Never))
130 } else {
131 Arc::from(approval_gate.handler())
132 };
133
134 let (tx, rx) = mpsc::unbounded_channel();
135 let tool_ctx = ToolContext {
136 cwd: config.cwd.clone(),
137 config: Arc::clone(&config),
138 approval,
139 };
140 let provider_name = Arc::new(RwLock::new(provider_name));
141 let agent = Arc::new(AgentLoop::new(
142 Arc::clone(&config),
143 Arc::clone(&model),
144 provider,
145 provider_name.read().expect("provider lock").clone(),
146 tool_ctx,
147 mcp,
148 Some(tx),
149 ));
150
151 let mut stdout = io::stdout();
152 enable_raw_mode()?;
153 stdout.execute(EnableBracketedPaste)?;
154 stdout.execute(PushKeyboardEnhancementFlags(
155 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES,
156 ))?;
157 stdout.execute(EnterAlternateScreen)?;
158 let mut terminal = ratatui::init();
159
160 let mut state = AppState {
161 lines: vec![ChatLine {
162 text: codei_i18n::t("app_tagline"),
163 style: Style::default().fg(Color::Cyan),
164 }],
165 input: String::new(),
166 model_name: model.read().expect("model lock").clone(),
167 provider_label: provider_name.read().expect("provider lock").clone(),
168 status: t("tui_status_idle"),
169 assistant_buf: String::new(),
170 running: false,
171 pending_approval: None,
172 turn_task: None,
173 chat_scroll: 0,
174 chat_follow_bottom: true,
175 cursor_visible: true,
176 last_blink: Instant::now(),
177 completion_index: 0,
178 slash_completion_dismissed: false,
179 pending_quit: false,
180 token_usage: Usage::default(),
181 last_turn_usage: None,
182 input_history: InputHistory::new(),
183 input_cursor: 0,
184 pending_cancel: false,
185 };
186
187 let mut runtime = AppRuntime {
188 agent,
189 session: Arc::new(Mutex::new(session)),
190 store: Arc::new(store),
191 config,
192 model,
193 provider_name,
194 approval_gate,
195 rx,
196 };
197
198 let result = run_app(&mut terminal, &mut runtime, &mut state).await;
199
200 disable_raw_mode()?;
201 let _ = stdout.execute(DisableBracketedPaste);
202 let _ = stdout.execute(PopKeyboardEnhancementFlags);
203 stdout.execute(LeaveAlternateScreen)?;
204 ratatui::restore();
205
206 result
207}
208
209struct AppRuntime {
210 agent: Arc<AgentLoop>,
211 session: Arc<Mutex<Session>>,
212 store: Arc<SessionStore>,
213 config: Arc<ResolvedConfig>,
214 model: Arc<RwLock<String>>,
215 provider_name: Arc<RwLock<String>>,
216 approval_gate: Arc<SharedApprovalGate>,
217 rx: mpsc::UnboundedReceiver<AgentEvent>,
218}
219
220struct AppState {
221 lines: Vec<ChatLine>,
222 input: String,
223 model_name: String,
224 provider_label: String,
225 status: String,
226 assistant_buf: String,
227 running: bool,
228 pending_approval: Option<codei_tools::ApprovalRequest>,
229 turn_task: Option<JoinHandle<Result<codei_agent::TurnOutcome, AgentError>>>,
230 chat_scroll: u16,
231 chat_follow_bottom: bool,
232 cursor_visible: bool,
233 last_blink: Instant,
234 completion_index: usize,
235 slash_completion_dismissed: bool,
236 pending_quit: bool,
237 token_usage: Usage,
238 last_turn_usage: Option<Usage>,
239 input_history: InputHistory,
240 input_cursor: usize,
241 pending_cancel: bool,
242}
243
244async fn run_app(
245 terminal: &mut DefaultTerminal,
246 runtime: &mut AppRuntime,
247 state: &mut AppState,
248) -> Result<()> {
249 loop {
250 poll_turn_task(state).await;
251
252 while let Ok(event) = runtime.rx.try_recv() {
253 match event {
254 AgentEvent::AssistantDelta { text } => {
255 state.assistant_buf.push_str(&text);
256 if let Some(last) = state.lines.last_mut() {
257 if last.style == Style::default() {
258 last.text.push_str(&text);
259 continue;
260 }
261 }
262 state.lines.push(ChatLine {
263 text: text.clone(),
264 style: Style::default(),
265 });
266 }
267 AgentEvent::ToolStarted { name, args } => {
268 flush_assistant(&mut state.lines, &mut state.assistant_buf);
269 state.lines.push(ChatLine {
270 text: format!("[tool:{name}] {args}"),
271 style: Style::default().fg(Color::Yellow),
272 });
273 }
274 AgentEvent::ToolFinished { name, result } => {
275 let prefix = if result.is_error {
276 t("tui_tool_status_error")
277 } else {
278 t("tui_tool_status_ok")
279 };
280 state.lines.push(ChatLine {
281 text: format!("[tool:{name}:{prefix}] {}", truncate(&result.content, 200)),
282 style: Style::default().fg(Color::DarkGray),
283 });
284 }
285 AgentEvent::TurnComplete { usage } => {
286 flush_assistant(&mut state.lines, &mut state.assistant_buf);
287 if let Some(u) = usage {
288 state.token_usage.add_assign(u);
289 state.last_turn_usage = Some(u);
290 }
291 state.status = t("tui_status_idle");
292 state.running = false;
293 state.turn_task = None;
294 }
295 AgentEvent::Error { message } => {
296 state.lines.push(ChatLine {
297 text: t_fmt("tui_error_prefix", &[("message", &message)]),
298 style: Style::default().fg(Color::Red),
299 });
300 state.status = t("tui_status_error");
301 state.running = false;
302 state.turn_task = None;
303 }
304 }
305 }
306
307 if state.pending_approval.is_none() {
308 state.pending_approval = runtime.approval_gate.take_pending().await;
309 }
310
311 if state.last_blink.elapsed() >= Duration::from_millis(530) {
312 state.cursor_visible = !state.cursor_visible;
313 state.last_blink = Instant::now();
314 }
315
316 let raw_slash_hints = if state.input.contains('\n') {
317 Vec::new()
318 } else {
319 filter_slash_hints(&state.input)
320 };
321 let slash_hints = if state.slash_completion_dismissed {
322 Vec::new()
323 } else {
324 raw_slash_hints.clone()
325 };
326 if slash_hints.is_empty() {
327 state.completion_index = 0;
328 } else if state.completion_index >= slash_hints.len() {
329 state.completion_index = slash_hints.len().saturating_sub(1);
330 }
331
332 terminal.draw(|frame| {
333 let completion_rows = if slash_hints.is_empty() {
334 0
335 } else {
336 slash_hints.len().min(6) as u16 + 2
337 };
338
339 let input_height = input_box_height(&state.input);
340
341 let mut constraints = vec![
342 Constraint::Min(5),
343 Constraint::Length(input_height),
344 Constraint::Length(1),
345 ];
346 if completion_rows > 0 {
347 constraints.insert(1, Constraint::Length(completion_rows));
348 }
349
350 let chunks = Layout::default()
351 .direction(Direction::Vertical)
352 .constraints(constraints)
353 .split(frame.area());
354
355 let chat_area = chunks[0];
356 let (input_idx, status_idx) = if completion_rows > 0 { (2, 3) } else { (1, 2) };
357
358 let wrapped_lines = wrap_chat_lines(&state.lines, chat_area.width.saturating_sub(2));
359 let visible_height = chat_area.height.saturating_sub(2) as usize;
360 let total_lines = wrapped_lines.len();
361 let max_scroll = total_lines.saturating_sub(visible_height) as u16;
362 if state.chat_follow_bottom {
363 state.chat_scroll = max_scroll;
364 } else {
365 state.chat_scroll = state.chat_scroll.min(max_scroll);
366 state.chat_follow_bottom = state.chat_scroll >= max_scroll;
367 }
368
369 let chat_widget = Paragraph::new(wrapped_lines)
370 .wrap(Wrap { trim: false })
371 .scroll((state.chat_scroll, 0))
372 .block(Block::default().borders(Borders::ALL).title(t_fmt(
373 "tui_chat_title",
374 &[
375 ("provider", &state.provider_label),
376 ("model", &state.model_name),
377 ("cwd", &runtime.config.cwd.display().to_string()),
378 ],
379 )));
380 frame.render_widget(chat_widget, chat_area);
381
382 if total_lines > visible_height {
383 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
384 .begin_symbol(Some("↑"))
385 .end_symbol(Some("↓"));
386 let mut scrollbar_state = ScrollbarState::new(total_lines)
387 .position(state.chat_scroll as usize)
388 .viewport_content_length(visible_height);
389 frame.render_stateful_widget(
390 scrollbar,
391 chat_area.inner(Margin {
392 vertical: 1,
393 horizontal: 0,
394 }),
395 &mut scrollbar_state,
396 );
397 }
398
399 if completion_rows > 0 {
400 render_slash_completions(frame, chunks[1], &slash_hints, state.completion_index);
401 }
402
403 let input_area = chunks[input_idx];
404 let input_title = if state.pending_cancel {
405 t("tui_input_cancel")
406 } else if state.pending_quit {
407 t("tui_input_quit")
408 } else if state.pending_approval.is_some() {
409 t("tui_input_approval")
410 } else if state.running {
411 t("tui_input_running")
412 } else {
413 t("tui_input_normal")
414 };
415 let input_widget = Paragraph::new(input_display_text(&state.input))
416 .block(Block::default().borders(Borders::ALL).title(input_title));
417 frame.render_widget(input_widget, input_area);
418
419 if state.cursor_visible
420 && !state.running
421 && state.pending_approval.is_none()
422 && !state.pending_quit
423 && !state.pending_cancel
424 && input_area.width > 2
425 {
426 let (cursor_x, cursor_y) =
427 input_cursor_pos(&state.input, state.input_cursor, input_area);
428 frame.set_cursor_position((cursor_x, cursor_y));
429 }
430
431 let status_line = Paragraph::new(t_fmt(
432 "tui_status_bar",
433 &[
434 ("status", &state.status),
435 (
436 "session",
437 &runtime
438 .session
439 .try_lock()
440 .map(|s| s.id.clone())
441 .unwrap_or_else(|_| "?".into()),
442 ),
443 ("input_tokens", &state.token_usage.input_tokens.to_string()),
444 (
445 "output_tokens",
446 &state.token_usage.output_tokens.to_string(),
447 ),
448 ],
449 ));
450 frame.render_widget(status_line, chunks[status_idx]);
451
452 if let Some(req) = &state.pending_approval {
453 render_approval_modal(frame, frame.area(), req);
454 } else if state.pending_cancel {
455 render_cancel_modal(frame, frame.area());
456 } else if state.pending_quit {
457 render_quit_modal(frame, frame.area());
458 }
459 })?;
460
461 if event::poll(Duration::from_millis(50))? {
462 match event::read()? {
463 Event::Paste(text)
464 if !state.running
465 && state.pending_approval.is_none()
466 && !state.pending_quit
467 && !state.pending_cancel =>
468 {
469 insert_input_text(state, &text);
470 continue;
471 }
472 Event::Key(key) => {
473 if key.modifiers.contains(KeyModifiers::CONTROL)
474 && key.code == KeyCode::Char('c')
475 {
476 if state.pending_approval.is_some() {
477 runtime.approval_gate.respond(false).await;
478 state.pending_approval = None;
479 } else if state.pending_cancel {
480 cancel_running_turn(state);
481 } else if state.running {
482 state.pending_cancel = true;
483 } else if state.pending_quit {
484 break;
485 } else {
486 state.pending_quit = true;
487 }
488 continue;
489 }
490
491 if state.pending_cancel {
492 match key.code {
493 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
494 cancel_running_turn(state);
495 }
496 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
497 state.pending_cancel = false;
498 }
499 _ => {}
500 }
501 continue;
502 }
503
504 if state.pending_quit {
505 match key.code {
506 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => break,
507 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
508 state.pending_quit = false;
509 }
510 _ => {}
511 }
512 continue;
513 }
514
515 if state.pending_approval.is_some() {
516 match key.code {
517 KeyCode::Char('y') | KeyCode::Char('Y') => {
518 runtime.approval_gate.respond(true).await;
519 state.pending_approval = None;
520 }
521 KeyCode::Char('a') | KeyCode::Char('A') => {
522 runtime.approval_gate.approve_always().await;
523 state.pending_approval = None;
524 }
525 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
526 runtime.approval_gate.respond(false).await;
527 state.pending_approval = None;
528 }
529 _ => {}
530 }
531 continue;
532 }
533
534 if state.running {
535 continue;
536 }
537
538 if !raw_slash_hints.is_empty() && !state.slash_completion_dismissed {
539 match key.code {
540 KeyCode::Up => {
541 state.completion_index = state.completion_index.saturating_sub(1);
542 continue;
543 }
544 KeyCode::Down => {
545 state.completion_index = (state.completion_index + 1)
546 .min(raw_slash_hints.len().saturating_sub(1));
547 continue;
548 }
549 KeyCode::Tab => {
550 apply_slash_completion(
551 state,
552 raw_slash_hints[state.completion_index],
553 );
554 continue;
555 }
556 KeyCode::Esc => {
557 state.slash_completion_dismissed = true;
558 state.completion_index = 0;
559 continue;
560 }
561 _ => {}
562 }
563 }
564
565 match key.code {
566 KeyCode::Up => {
567 if let Some(text) = state.input_history.browse_older(&state.input) {
568 mark_input_edited(state);
569 state.input = text;
570 state.input_cursor = state.input.len();
571 }
572 continue;
573 }
574 KeyCode::Down => {
575 if let Some(text) = state.input_history.browse_newer() {
576 mark_input_edited(state);
577 state.input = text;
578 state.input_cursor = state.input.len();
579 }
580 continue;
581 }
582 KeyCode::Left => {
583 state.input_cursor =
584 prev_char_boundary(&state.input, state.input_cursor);
585 continue;
586 }
587 KeyCode::Right => {
588 state.input_cursor =
589 next_char_boundary(&state.input, state.input_cursor);
590 continue;
591 }
592 KeyCode::PageUp => {
593 scroll_chat(state, -3);
594 continue;
595 }
596 KeyCode::PageDown => {
597 scroll_chat(state, 3);
598 continue;
599 }
600 KeyCode::Home => {
601 state.chat_scroll = 0;
602 state.chat_follow_bottom = false;
603 continue;
604 }
605 KeyCode::End => {
606 state.chat_follow_bottom = true;
607 continue;
608 }
609 KeyCode::Esc => {
610 if !state.input.is_empty() {
611 state.input.clear();
612 state.input_cursor = 0;
613 state.completion_index = 0;
614 state.slash_completion_dismissed = false;
615 state.input_history.clear_browse();
616 }
617 continue;
618 }
619 KeyCode::Char('y') | KeyCode::Char('Y')
620 if key.modifiers.contains(KeyModifiers::CONTROL)
621 && key.modifiers.contains(KeyModifiers::SHIFT) =>
622 {
623 copy_chat_with_status(state, CopyScope::All);
624 continue;
625 }
626 KeyCode::Char('l') | KeyCode::Char('L')
627 if key.modifiers.contains(KeyModifiers::CONTROL)
628 && key.modifiers.contains(KeyModifiers::SHIFT) =>
629 {
630 copy_chat_with_status(state, CopyScope::LastAssistant);
631 continue;
632 }
633 KeyCode::Enter if key_inserts_newline(&key) => {
634 insert_newline_at_cursor(state);
635 }
636 KeyCode::Enter => {
637 let line = std::mem::take(&mut state.input);
638 state.input_cursor = 0;
639 state.completion_index = 0;
640 state.input_history.clear_browse();
641 if line.trim().is_empty() {
642 continue;
643 }
644 state.input_history.push(line.clone());
645 state.chat_follow_bottom = true;
646 state.lines.push(ChatLine {
647 text: format_user_prompt(&line),
648 style: Style::default().fg(Color::Green),
649 });
650
651 match parse_input(&line) {
652 Input::SlashCommand(SlashCommand::Copy) => {
653 copy_chat_with_status(state, CopyScope::All);
654 }
655 Input::SlashCommand(SlashCommand::CopyLast) => {
656 copy_chat_with_status(state, CopyScope::LastAssistant);
657 }
658 Input::SlashCommand(cmd) => {
659 let mut session = runtime.session.lock().await;
660 let mut ctx = SlashContext {
661 session: &mut session,
662 store: &runtime.store,
663 model: &runtime.model,
664 provider_name: &runtime.provider_name,
665 agent: runtime.agent.as_ref(),
666 token_usage: &mut state.token_usage,
667 last_turn_usage: &mut state.last_turn_usage,
668 };
669 match handle_slash(cmd, &mut ctx).await? {
670 SlashAction::Exit => break,
671 SlashAction::Message(text) => state.lines.push(ChatLine {
672 text,
673 style: Style::default().fg(Color::Cyan),
674 }),
675 SlashAction::Continue => {}
676 }
677 state.model_name =
678 runtime.model.read().expect("model lock").clone();
679 state.provider_label = runtime
680 .provider_name
681 .read()
682 .expect("provider lock")
683 .clone();
684 }
685 Input::UserMessage(msg) => {
686 start_agent_turn(runtime, state, msg);
687 }
688 }
689 }
690 KeyCode::Backspace => backspace_at_cursor(state),
691 KeyCode::Delete => delete_at_cursor(state),
692 KeyCode::Char(c) => {
693 if key.modifiers.contains(KeyModifiers::CONTROL) {
694 if c == 'h' || c == '\x08' {
695 backspace_at_cursor(state);
696 } else if c == 'j' {
697 insert_newline_at_cursor(state);
698 }
699 continue;
700 }
701 if c == '\x7f' {
702 backspace_at_cursor(state);
703 continue;
704 }
705 if c == '\n' {
706 insert_newline_at_cursor(state);
707 continue;
708 }
709 if !c.is_control() {
710 insert_char_at_cursor(state, c);
711 }
712 }
713 _ => {}
714 }
715 }
716 _ => {}
717 }
718 } else if state.running {
719 tokio::task::yield_now().await;
720 }
721 }
722
723 Ok(())
724}
725
726fn start_agent_turn(runtime: &AppRuntime, state: &mut AppState, msg: String) {
727 state.status = t("tui_status_running");
728 state.running = true;
729 state.chat_follow_bottom = true;
730 state.assistant_buf.clear();
731 state.lines.push(ChatLine {
732 text: String::new(),
733 style: Style::default(),
734 });
735
736 let agent = Arc::clone(&runtime.agent);
737 let session = Arc::clone(&runtime.session);
738 let store = Arc::clone(&runtime.store);
739
740 state.turn_task = Some(tokio::spawn(async move {
741 let mut session = session.lock().await;
742 agent.run_turn(&mut session, &msg, &store).await
743 }));
744}
745
746async fn poll_turn_task(state: &mut AppState) {
747 let finished = state
748 .turn_task
749 .as_ref()
750 .is_some_and(|task| task.is_finished());
751 if !finished {
752 return;
753 }
754 let Some(task) = state.turn_task.take() else {
755 return;
756 };
757 match task.await {
758 Ok(Ok(_)) => {}
759 Ok(Err(err)) => {
760 state.lines.push(ChatLine {
761 text: t_fmt("tui_error_prefix", &[("message", &err.to_string())]),
762 style: Style::default().fg(Color::Red),
763 });
764 state.status = t("tui_status_error");
765 state.running = false;
766 }
767 Err(err) if err.is_cancelled() => {
768 if state.running {
769 finish_cancel_turn(state);
770 }
771 }
772 Err(err) => {
773 state.lines.push(ChatLine {
774 text: t_fmt("tui_agent_task_failed", &[("message", &err.to_string())]),
775 style: Style::default().fg(Color::Red),
776 });
777 state.status = t("tui_status_error");
778 state.running = false;
779 }
780 }
781}
782
783fn key_inserts_newline(key: &KeyEvent) -> bool {
784 match key.code {
785 KeyCode::Enter => key
786 .modifiers
787 .intersects(KeyModifiers::SHIFT | KeyModifiers::ALT | KeyModifiers::CONTROL),
788 KeyCode::Char('\n') => true,
789 _ => false,
790 }
791}
792
793fn normalize_line_endings(text: &str) -> String {
794 text.replace("\r\n", "\n").replace('\r', "\n")
795}
796
797fn mark_input_edited(state: &mut AppState) {
798 state.slash_completion_dismissed = false;
799}
800
801fn insert_input_text(state: &mut AppState, text: &str) {
802 mark_input_edited(state);
803 state.input_history.clear_browse();
804 let pos = state.input_cursor.min(state.input.len());
805 let normalized = normalize_line_endings(text);
806 state.input.insert_str(pos, &normalized);
807 state.input_cursor = pos + normalized.len();
808}
809
810fn insert_char_at_cursor(state: &mut AppState, ch: char) {
811 mark_input_edited(state);
812 state.input_history.clear_browse();
813 let pos = state.input_cursor.min(state.input.len());
814 state.input.insert(pos, ch);
815 state.input_cursor = pos + ch.len_utf8();
816}
817
818fn insert_newline_at_cursor(state: &mut AppState) {
819 mark_input_edited(state);
820 state.input_history.clear_browse();
821 let pos = state.input_cursor.min(state.input.len());
822 state.input.insert(pos, '\n');
823 state.input_cursor = pos + 1;
824}
825
826fn backspace_at_cursor(state: &mut AppState) {
827 if state.input_cursor == 0 {
828 return;
829 }
830 mark_input_edited(state);
831 state.input_history.clear_browse();
832 let start = prev_char_boundary(&state.input, state.input_cursor);
833 state.input.drain(start..state.input_cursor);
834 state.input_cursor = start;
835}
836
837fn delete_at_cursor(state: &mut AppState) {
838 if state.input_cursor >= state.input.len() {
839 return;
840 }
841 mark_input_edited(state);
842 state.input_history.clear_browse();
843 let end = next_char_boundary(&state.input, state.input_cursor);
844 state.input.drain(state.input_cursor..end);
845}
846
847fn prev_char_boundary(text: &str, cursor: usize) -> usize {
848 let cursor = cursor.min(text.len());
849 if cursor == 0 {
850 return 0;
851 }
852 let mut pos = cursor - 1;
853 while pos > 0 && !text.is_char_boundary(pos) {
854 pos -= 1;
855 }
856 if !text.is_char_boundary(pos) {
857 return 0;
858 }
859 pos
860}
861
862fn next_char_boundary(text: &str, cursor: usize) -> usize {
863 let cursor = cursor.min(text.len());
864 if cursor >= text.len() {
865 return text.len();
866 }
867 let mut pos = cursor + 1;
868 while pos < text.len() && !text.is_char_boundary(pos) {
869 pos += 1;
870 }
871 pos
872}
873
874fn cancel_running_turn(state: &mut AppState) {
875 if let Some(task) = state.turn_task.take() {
876 task.abort();
877 }
878 finish_cancel_turn(state);
879 state.pending_cancel = false;
880}
881
882fn finish_cancel_turn(state: &mut AppState) {
883 state.running = false;
884 state.status = t("tui_status_idle");
885 state.assistant_buf.clear();
886 if let Some(last) = state.lines.last() {
887 if last.style == Style::default() && last.text.is_empty() {
888 state.lines.pop();
889 } else if let Some(last) = state.lines.last_mut() {
890 if last.style == Style::default() {
891 last.text
892 .push_str(&format!("\n\n[{}]", t("tui_turn_cancelled")));
893 }
894 }
895 }
896}
897
898fn input_display_text(input: &str) -> Text<'static> {
899 Text::from(input_display_lines(input))
900}
901
902fn input_display_lines(input: &str) -> Vec<Line<'static>> {
903 if input.is_empty() {
904 return vec![Line::from("")];
905 }
906 input
907 .split('\n')
908 .map(|line| Line::from(line.to_string()))
909 .collect()
910}
911
912fn format_user_prompt(text: &str) -> String {
913 if !text.contains('\n') {
914 return format!("> {text}");
915 }
916 text.lines()
917 .map(|line| format!("> {line}"))
918 .collect::<Vec<_>>()
919 .join("\n")
920}
921
922fn input_box_height(text: &str) -> u16 {
923 let line_count = input_display_lines(text).len().max(1);
924 (line_count as u16 + 2).clamp(INPUT_MIN_HEIGHT, INPUT_MAX_HEIGHT)
925}
926
927fn input_cursor_pos(input: &str, cursor: usize, area: Rect) -> (u16, u16) {
928 let inner_left = area.x + 1;
929 let inner_bottom = area.y + area.height.saturating_sub(2);
930 let cursor = cursor.min(input.len());
931 if input.is_empty() {
932 return (inner_left, area.y + 1);
933 }
934
935 let before = &input[..cursor];
936 let line_idx = before.chars().filter(|&c| c == '\n').count();
937 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
938 let col = input[line_start..cursor].width();
939
940 let y = area.y + 1 + line_idx as u16;
941 let x = inner_left + col as u16;
942 let max_x = area.x + area.width.saturating_sub(2);
943 (x.min(max_x), y.min(inner_bottom))
944}
945
946fn scroll_chat(state: &mut AppState, delta: i16) {
947 state.chat_follow_bottom = false;
948 if delta < 0 {
949 state.chat_scroll = state.chat_scroll.saturating_sub((-delta) as u16);
950 } else {
951 state.chat_scroll = state.chat_scroll.saturating_add(delta as u16);
952 }
953}
954
955#[derive(Clone, Copy)]
956enum CopyScope {
957 All,
958 LastAssistant,
959}
960
961fn copy_chat_with_status(state: &mut AppState, scope: CopyScope) {
962 let text = match scope {
963 CopyScope::All => chat_text_all(&state.lines),
964 CopyScope::LastAssistant => match last_assistant_text(&state.lines) {
965 Some(text) => text,
966 None => {
967 state.status = t("tui_copy_nothing");
968 return;
969 }
970 },
971 };
972 match copy_to_clipboard(&text) {
973 Ok(()) => {
974 state.status = t_fmt(
975 "tui_copy_ok",
976 &[("count", &text.chars().count().to_string())],
977 );
978 }
979 Err(err) => {
980 state.status = t_fmt("tui_copy_failed", &[("error", &format!("{err:#}"))]);
981 }
982 }
983}
984
985fn chat_text_all(lines: &[ChatLine]) -> String {
986 lines
987 .iter()
988 .map(|line| line.text.as_str())
989 .collect::<Vec<_>>()
990 .join("\n")
991}
992
993fn last_assistant_text(lines: &[ChatLine]) -> Option<String> {
994 lines
995 .iter()
996 .rev()
997 .find(|line| {
998 line.style == Style::default()
999 && !line.text.starts_with("[tool:")
1000 && !is_chat_error_line(&line.text)
1001 })
1002 .map(|line| line.text.clone())
1003}
1004
1005fn apply_slash_completion(state: &mut AppState, hint: &SlashHint) {
1006 mark_input_edited(state);
1007 state.input = hint.command.to_string();
1008 if matches!(
1009 hint.command,
1010 "/model" | "/provider" | "/session resume" | "/skill show" | "/language"
1011 ) {
1012 state.input.push(' ');
1013 }
1014 state.input_cursor = state.input.len();
1015 state.input_history.clear_browse();
1016}
1017
1018fn render_slash_completions(
1019 frame: &mut ratatui::Frame,
1020 area: Rect,
1021 hints: &[&SlashHint],
1022 selected: usize,
1023) {
1024 let items: Vec<ListItem> = hints
1025 .iter()
1026 .enumerate()
1027 .map(|(idx, hint)| {
1028 let style = if idx == selected {
1029 Style::default()
1030 .fg(Color::Black)
1031 .bg(Color::Cyan)
1032 .add_modifier(Modifier::BOLD)
1033 } else {
1034 Style::default().fg(Color::Gray)
1035 };
1036 ListItem::new(Line::from(vec![
1037 Span::styled(format!("{:<18}", hint.command), style),
1038 Span::styled(t(hint.description_key), style),
1039 ]))
1040 })
1041 .collect();
1042 let widget = List::new(items).block(
1043 Block::default()
1044 .borders(Borders::ALL)
1045 .title(t("tui_commands_title"))
1046 .style(Style::default().fg(Color::DarkGray)),
1047 );
1048 frame.render_widget(Clear, area);
1049 frame.render_widget(widget, area);
1050}
1051
1052fn wrap_chat_lines(lines: &[ChatLine], area_width: u16) -> Vec<Line<'static>> {
1053 let inner = area_width.saturating_sub(2) as usize;
1054 let mut rendered = Vec::new();
1055 for line in lines {
1056 for segment in wrap_text(&line.text, inner.max(1)) {
1057 rendered.push(Line::from(Span::styled(segment, line.style)));
1058 }
1059 }
1060 rendered
1061}
1062
1063fn wrap_text(text: &str, width: usize) -> Vec<String> {
1064 if text.is_empty() {
1065 return vec![String::new()];
1066 }
1067 let mut lines = Vec::new();
1068 let mut current = String::new();
1069
1070 for ch in text.chars() {
1071 if ch == '\n' {
1072 lines.push(std::mem::take(&mut current));
1073 continue;
1074 }
1075 if current.chars().count() + 1 > width && !current.is_empty() {
1076 lines.push(std::mem::take(&mut current));
1077 }
1078 current.push(ch);
1079 }
1080 if !current.is_empty() {
1081 lines.push(current);
1082 }
1083
1084 if lines.is_empty() {
1085 lines.push(String::new());
1086 }
1087
1088 let mut refined = Vec::new();
1090 for line in lines {
1091 if line.chars().count() <= width {
1092 refined.push(line);
1093 continue;
1094 }
1095 let mut rest: String = line;
1096 while !rest.is_empty() {
1097 if rest.chars().count() <= width {
1098 refined.push(rest);
1099 break;
1100 }
1101 let byte_idx = rest
1102 .char_indices()
1103 .nth(width)
1104 .map(|(i, _)| i)
1105 .unwrap_or(rest.len());
1106 let mut break_at = byte_idx;
1107 if let Some(space) = rest[..byte_idx].rfind(' ') {
1108 if space > 0 {
1109 break_at = space;
1110 }
1111 }
1112 let (part, remainder) = rest.split_at(break_at);
1113 refined.push(part.trim_end().to_string());
1114 rest = remainder.trim_start().to_string();
1115 }
1116 }
1117 refined
1118}
1119
1120fn render_cancel_modal(frame: &mut ratatui::Frame, area: Rect) {
1121 let popup = centered_rect(60, 20, area);
1122 frame.render_widget(Clear, popup);
1123 let widget = Paragraph::new(t("tui_cancel_body"))
1124 .wrap(Wrap { trim: false })
1125 .style(Style::default().add_modifier(Modifier::BOLD))
1126 .block(
1127 Block::default()
1128 .borders(Borders::ALL)
1129 .title(t("tui_cancel_title"))
1130 .style(Style::default().fg(Color::Yellow)),
1131 );
1132 frame.render_widget(widget, popup);
1133}
1134
1135fn render_quit_modal(frame: &mut ratatui::Frame, area: Rect) {
1136 let popup = centered_rect(60, 20, area);
1137 frame.render_widget(Clear, popup);
1138 let widget = Paragraph::new(t("tui_quit_body"))
1139 .wrap(Wrap { trim: false })
1140 .style(Style::default().add_modifier(Modifier::BOLD))
1141 .block(
1142 Block::default()
1143 .borders(Borders::ALL)
1144 .title(t("tui_quit_title"))
1145 .style(Style::default().fg(Color::Yellow)),
1146 );
1147 frame.render_widget(widget, popup);
1148}
1149
1150fn render_approval_modal(
1151 frame: &mut ratatui::Frame,
1152 area: Rect,
1153 request: &codei_tools::ApprovalRequest,
1154) {
1155 let popup = centered_rect(70, 30, area);
1156 frame.render_widget(Clear, popup);
1157 let text = t_fmt(
1158 "tui_tool_approval_body",
1159 &[
1160 ("name", &request.tool_name),
1161 ("args", &request.arguments.to_string()),
1162 ],
1163 );
1164 let widget = Paragraph::new(text)
1165 .wrap(Wrap { trim: false })
1166 .style(Style::default().add_modifier(Modifier::BOLD))
1167 .block(
1168 Block::default()
1169 .borders(Borders::ALL)
1170 .title(t("tui_tool_approval_title"))
1171 .style(Style::default().fg(Color::Yellow)),
1172 );
1173 frame.render_widget(widget, popup);
1174}
1175
1176fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
1177 let popup_layout = Layout::default()
1178 .direction(Direction::Vertical)
1179 .constraints([
1180 Constraint::Percentage((100 - percent_y) / 2),
1181 Constraint::Percentage(percent_y),
1182 Constraint::Percentage((100 - percent_y) / 2),
1183 ])
1184 .split(area);
1185 Layout::default()
1186 .direction(Direction::Horizontal)
1187 .constraints([
1188 Constraint::Percentage((100 - percent_x) / 2),
1189 Constraint::Percentage(percent_x),
1190 Constraint::Percentage((100 - percent_x) / 2),
1191 ])
1192 .split(popup_layout[1])[1]
1193}
1194
1195fn flush_assistant(lines: &mut [ChatLine], assistant_buf: &mut String) {
1196 if !assistant_buf.is_empty() {
1197 assistant_buf.clear();
1198 }
1199 let _ = lines;
1200}
1201
1202fn truncate(s: &str, max_chars: usize) -> String {
1203 if s.chars().count() <= max_chars {
1204 s.to_string()
1205 } else {
1206 let truncated: String = s.chars().take(max_chars).collect();
1207 format!("{truncated}...")
1208 }
1209}
1210
1211fn is_chat_error_line(text: &str) -> bool {
1212 text.starts_with("Error:") || text.starts_with("错误:")
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217 use super::*;
1218
1219 #[test]
1220 fn normalize_line_endings_unifies_crlf_and_cr() {
1221 assert_eq!(normalize_line_endings("a\r\nb\rc"), "a\nb\nc");
1222 }
1223
1224 #[test]
1225 fn input_display_lines_preserves_trailing_newline() {
1226 let lines = input_display_lines("a\nb\n");
1227 assert_eq!(lines.len(), 3);
1228 assert_eq!(lines[2], Line::from(""));
1229 }
1230
1231 #[test]
1232 fn key_inserts_newline_for_alt_enter_and_ctrl_enter() {
1233 assert!(key_inserts_newline(&KeyEvent::new(
1234 KeyCode::Enter,
1235 KeyModifiers::ALT,
1236 )));
1237 assert!(key_inserts_newline(&KeyEvent::new(
1238 KeyCode::Enter,
1239 KeyModifiers::CONTROL,
1240 )));
1241 assert!(!key_inserts_newline(&KeyEvent::new(
1242 KeyCode::Enter,
1243 KeyModifiers::empty(),
1244 )));
1245 }
1246
1247 #[test]
1248 fn input_history_browses_submitted_messages() {
1249 let mut history = InputHistory::new();
1250 history.push("first".into());
1251 history.push("second".into());
1252
1253 assert_eq!(history.browse_older(""), as_deref("second"));
1254 assert_eq!(history.browse_older("ignored"), as_deref("first"));
1255 assert_eq!(history.browse_older("ignored"), None);
1256 assert_eq!(history.browse_newer(), as_deref("second"));
1257 assert_eq!(history.browse_newer(), Some(String::new()));
1258 }
1259
1260 #[test]
1261 fn input_history_restores_draft_after_browse() {
1262 let mut history = InputHistory::new();
1263 history.push("old".into());
1264 assert_eq!(history.browse_older("draft text"), as_deref("old"));
1265 assert_eq!(history.browse_newer(), Some("draft text".into()));
1266 }
1267
1268 fn as_deref(value: &str) -> Option<String> {
1269 Some(value.to_string())
1270 }
1271
1272 #[test]
1273 fn input_cursor_pos_tracks_byte_offset() {
1274 use ratatui::layout::Rect;
1275
1276 let area = Rect::new(0, 0, 40, 5);
1277 let input = "ab\ncd";
1278 let (x, y) = input_cursor_pos(input, 4, area);
1279 assert_eq!(y, 2);
1280 assert_eq!(x, 2);
1281 }
1282
1283 #[test]
1284 fn prev_char_boundary_skips_utf8() {
1285 let text = "aéb";
1286 let end = text.len();
1287 let mid = prev_char_boundary(text, end);
1288 assert_eq!(&text[..mid], "aé");
1289 }
1290
1291 #[test]
1292 fn truncate_respects_utf8_char_boundary() {
1293 let text = "终端优先的 AI 编程 Agent";
1294 let out = truncate(text, 5);
1295 assert!(out.ends_with("..."));
1296 assert_eq!(out.chars().count(), 5 + 3);
1297 }
1298}