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_session::{Session, SessionStore};
11use codei_tools::{handler_for_policy, ApprovalPolicy, SharedApprovalGate, ToolContext};
12use crossterm::event::{self, Event, KeyCode, KeyModifiers};
13use crossterm::terminal::{
14 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
15};
16use crossterm::ExecutableCommand;
17use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect};
18use ratatui::style::{Color, Modifier, Style};
19use ratatui::text::{Line, Span};
20use ratatui::widgets::{
21 Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
22 ScrollbarState, Wrap,
23};
24use ratatui::DefaultTerminal;
25use tokio::sync::{mpsc, Mutex};
26use tokio::task::JoinHandle;
27use unicode_width::UnicodeWidthStr;
28
29use crate::clipboard::copy_to_clipboard;
30use crate::launch::InteractiveLaunch;
31use crate::slash::{handle_slash, SlashAction, SlashContext};
32
33struct ChatLine {
34 text: String,
35 style: Style,
36}
37
38pub struct TuiOptions {
39 pub auto_approve: bool,
40}
41
42pub async fn run_tui(launch: InteractiveLaunch, opts: TuiOptions) -> Result<()> {
43 let InteractiveLaunch {
44 config,
45 provider,
46 provider_name,
47 model,
48 session,
49 store,
50 mcp,
51 } = launch;
52 let approval_gate = Arc::new(SharedApprovalGate::new());
53 let approval: Arc<dyn codei_tools::ApprovalHandler> = if opts.auto_approve {
54 Arc::from(handler_for_policy(ApprovalPolicy::Never))
55 } else {
56 Arc::from(approval_gate.handler())
57 };
58
59 let (tx, rx) = mpsc::unbounded_channel();
60 let tool_ctx = ToolContext {
61 cwd: config.cwd.clone(),
62 config: Arc::clone(&config),
63 approval,
64 };
65 let provider_name = Arc::new(RwLock::new(provider_name));
66 let agent = Arc::new(AgentLoop::new(
67 Arc::clone(&config),
68 Arc::clone(&model),
69 provider,
70 provider_name.read().expect("provider lock").clone(),
71 tool_ctx,
72 mcp,
73 Some(tx),
74 ));
75
76 let mut stdout = io::stdout();
77 enable_raw_mode()?;
78 stdout.execute(EnterAlternateScreen)?;
79 let mut terminal = ratatui::init();
80
81 let mut state = AppState {
82 lines: vec![ChatLine {
83 text: codei_i18n::t("app_tagline"),
84 style: Style::default().fg(Color::Cyan),
85 }],
86 input: String::new(),
87 model_name: model.read().expect("model lock").clone(),
88 provider_label: provider_name.read().expect("provider lock").clone(),
89 status: t("tui_status_idle"),
90 assistant_buf: String::new(),
91 running: false,
92 pending_approval: None,
93 turn_task: None,
94 chat_scroll: 0,
95 chat_follow_bottom: true,
96 cursor_visible: true,
97 last_blink: Instant::now(),
98 completion_index: 0,
99 };
100
101 let mut runtime = AppRuntime {
102 agent,
103 session: Arc::new(Mutex::new(session)),
104 store: Arc::new(store),
105 config,
106 model,
107 provider_name,
108 approval_gate,
109 rx,
110 };
111
112 let result = run_app(&mut terminal, &mut runtime, &mut state).await;
113
114 disable_raw_mode()?;
115 stdout.execute(LeaveAlternateScreen)?;
116 ratatui::restore();
117
118 result
119}
120
121struct AppRuntime {
122 agent: Arc<AgentLoop>,
123 session: Arc<Mutex<Session>>,
124 store: Arc<SessionStore>,
125 config: Arc<ResolvedConfig>,
126 model: Arc<RwLock<String>>,
127 provider_name: Arc<RwLock<String>>,
128 approval_gate: Arc<SharedApprovalGate>,
129 rx: mpsc::UnboundedReceiver<AgentEvent>,
130}
131
132struct AppState {
133 lines: Vec<ChatLine>,
134 input: String,
135 model_name: String,
136 provider_label: String,
137 status: String,
138 assistant_buf: String,
139 running: bool,
140 pending_approval: Option<codei_tools::ApprovalRequest>,
141 turn_task: Option<JoinHandle<Result<codei_agent::TurnOutcome, AgentError>>>,
142 chat_scroll: u16,
143 chat_follow_bottom: bool,
144 cursor_visible: bool,
145 last_blink: Instant,
146 completion_index: usize,
147}
148
149async fn run_app(
150 terminal: &mut DefaultTerminal,
151 runtime: &mut AppRuntime,
152 state: &mut AppState,
153) -> Result<()> {
154 loop {
155 poll_turn_task(state).await;
156
157 while let Ok(event) = runtime.rx.try_recv() {
158 match event {
159 AgentEvent::AssistantDelta { text } => {
160 state.assistant_buf.push_str(&text);
161 if let Some(last) = state.lines.last_mut() {
162 if last.style == Style::default() {
163 last.text.push_str(&text);
164 continue;
165 }
166 }
167 state.lines.push(ChatLine {
168 text: text.clone(),
169 style: Style::default(),
170 });
171 }
172 AgentEvent::ToolStarted { name, args } => {
173 flush_assistant(&mut state.lines, &mut state.assistant_buf);
174 state.lines.push(ChatLine {
175 text: format!("[tool:{name}] {args}"),
176 style: Style::default().fg(Color::Yellow),
177 });
178 }
179 AgentEvent::ToolFinished { name, result } => {
180 let prefix = if result.is_error {
181 t("tui_tool_status_error")
182 } else {
183 t("tui_tool_status_ok")
184 };
185 state.lines.push(ChatLine {
186 text: format!("[tool:{name}:{prefix}] {}", truncate(&result.content, 200)),
187 style: Style::default().fg(Color::DarkGray),
188 });
189 }
190 AgentEvent::TurnComplete { .. } => {
191 flush_assistant(&mut state.lines, &mut state.assistant_buf);
192 state.status = t("tui_status_idle");
193 state.running = false;
194 state.turn_task = None;
195 }
196 AgentEvent::Error { message } => {
197 state.lines.push(ChatLine {
198 text: t_fmt("tui_error_prefix", &[("message", &message)]),
199 style: Style::default().fg(Color::Red),
200 });
201 state.status = t("tui_status_error");
202 state.running = false;
203 state.turn_task = None;
204 }
205 }
206 }
207
208 if state.pending_approval.is_none() {
209 state.pending_approval = runtime.approval_gate.take_pending().await;
210 }
211
212 if state.last_blink.elapsed() >= Duration::from_millis(530) {
213 state.cursor_visible = !state.cursor_visible;
214 state.last_blink = Instant::now();
215 }
216
217 let slash_hints = filter_slash_hints(&state.input);
218 if slash_hints.is_empty() {
219 state.completion_index = 0;
220 } else if state.completion_index >= slash_hints.len() {
221 state.completion_index = slash_hints.len().saturating_sub(1);
222 }
223
224 terminal.draw(|frame| {
225 let completion_rows = if slash_hints.is_empty() {
226 0
227 } else {
228 slash_hints.len().min(6) as u16 + 2
229 };
230
231 let mut constraints = vec![
232 Constraint::Min(5),
233 Constraint::Length(3),
234 Constraint::Length(1),
235 ];
236 if completion_rows > 0 {
237 constraints.insert(1, Constraint::Length(completion_rows));
238 }
239
240 let chunks = Layout::default()
241 .direction(Direction::Vertical)
242 .constraints(constraints)
243 .split(frame.area());
244
245 let chat_area = chunks[0];
246 let (input_idx, status_idx) = if completion_rows > 0 { (2, 3) } else { (1, 2) };
247
248 let wrapped_lines = wrap_chat_lines(&state.lines, chat_area.width.saturating_sub(2));
249 let visible_height = chat_area.height.saturating_sub(2) as usize;
250 let total_lines = wrapped_lines.len();
251 let max_scroll = total_lines.saturating_sub(visible_height) as u16;
252 if state.chat_follow_bottom {
253 state.chat_scroll = max_scroll;
254 } else {
255 state.chat_scroll = state.chat_scroll.min(max_scroll);
256 state.chat_follow_bottom = state.chat_scroll >= max_scroll;
257 }
258
259 let chat_widget = Paragraph::new(wrapped_lines)
260 .wrap(Wrap { trim: false })
261 .scroll((state.chat_scroll, 0))
262 .block(Block::default().borders(Borders::ALL).title(t_fmt(
263 "tui_chat_title",
264 &[
265 ("provider", &state.provider_label),
266 ("model", &state.model_name),
267 ("cwd", &runtime.config.cwd.display().to_string()),
268 ],
269 )));
270 frame.render_widget(chat_widget, chat_area);
271
272 if total_lines > visible_height {
273 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
274 .begin_symbol(Some("↑"))
275 .end_symbol(Some("↓"));
276 let mut scrollbar_state = ScrollbarState::new(total_lines)
277 .position(state.chat_scroll as usize)
278 .viewport_content_length(visible_height);
279 frame.render_stateful_widget(
280 scrollbar,
281 chat_area.inner(Margin {
282 vertical: 1,
283 horizontal: 0,
284 }),
285 &mut scrollbar_state,
286 );
287 }
288
289 if completion_rows > 0 {
290 render_slash_completions(frame, chunks[1], &slash_hints, state.completion_index);
291 }
292
293 let input_area = chunks[input_idx];
294 let input_title = if state.pending_approval.is_some() {
295 t("tui_input_approval")
296 } else if state.running {
297 t("tui_input_running")
298 } else {
299 t("tui_input_normal")
300 };
301 let input_widget = Paragraph::new(state.input.as_str())
302 .block(Block::default().borders(Borders::ALL).title(input_title));
303 frame.render_widget(input_widget, input_area);
304
305 if state.cursor_visible
306 && !state.running
307 && state.pending_approval.is_none()
308 && input_area.width > 2
309 {
310 let cursor_x = input_area.x + 1 + state.input.width() as u16;
311 let cursor_y = input_area.y + 1;
312 let max_x = input_area.x + input_area.width.saturating_sub(2);
313 frame.set_cursor_position((cursor_x.min(max_x), cursor_y));
314 }
315
316 let status_line = Paragraph::new(t_fmt(
317 "tui_status_bar",
318 &[
319 ("status", &state.status),
320 (
321 "session",
322 &runtime
323 .session
324 .try_lock()
325 .map(|s| s.id.clone())
326 .unwrap_or_else(|_| "?".into()),
327 ),
328 ],
329 ));
330 frame.render_widget(status_line, chunks[status_idx]);
331
332 if let Some(req) = &state.pending_approval {
333 render_approval_modal(frame, frame.area(), req);
334 }
335 })?;
336
337 if event::poll(Duration::from_millis(50))? {
338 if let Event::Key(key) = event::read()? {
339 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
340 if state.pending_approval.is_some() {
341 runtime.approval_gate.respond(false).await;
342 state.pending_approval = None;
343 } else {
344 break;
345 }
346 }
347
348 if state.pending_approval.is_some() {
349 match key.code {
350 KeyCode::Char('y') | KeyCode::Char('Y') => {
351 runtime.approval_gate.respond(true).await;
352 state.pending_approval = None;
353 }
354 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
355 runtime.approval_gate.respond(false).await;
356 state.pending_approval = None;
357 }
358 _ => {}
359 }
360 continue;
361 }
362
363 if state.running {
364 continue;
365 }
366
367 if !slash_hints.is_empty() {
368 match key.code {
369 KeyCode::Up => {
370 state.completion_index = state.completion_index.saturating_sub(1);
371 continue;
372 }
373 KeyCode::Down => {
374 state.completion_index = (state.completion_index + 1)
375 .min(slash_hints.len().saturating_sub(1));
376 continue;
377 }
378 KeyCode::Tab => {
379 apply_slash_completion(state, slash_hints[state.completion_index]);
380 continue;
381 }
382 _ => {}
383 }
384 }
385
386 match key.code {
387 KeyCode::PageUp => {
388 scroll_chat(state, -3);
389 continue;
390 }
391 KeyCode::PageDown => {
392 scroll_chat(state, 3);
393 continue;
394 }
395 KeyCode::Home => {
396 state.chat_scroll = 0;
397 state.chat_follow_bottom = false;
398 continue;
399 }
400 KeyCode::End => {
401 state.chat_follow_bottom = true;
402 continue;
403 }
404 KeyCode::Char('y') | KeyCode::Char('Y')
405 if key.modifiers.contains(KeyModifiers::CONTROL)
406 && key.modifiers.contains(KeyModifiers::SHIFT) =>
407 {
408 copy_chat_with_status(state, CopyScope::All);
409 continue;
410 }
411 KeyCode::Char('l') | KeyCode::Char('L')
412 if key.modifiers.contains(KeyModifiers::CONTROL)
413 && key.modifiers.contains(KeyModifiers::SHIFT) =>
414 {
415 copy_chat_with_status(state, CopyScope::LastAssistant);
416 continue;
417 }
418 KeyCode::Enter => {
419 let line = std::mem::take(&mut state.input);
420 state.completion_index = 0;
421 if line.trim().is_empty() {
422 continue;
423 }
424 state.chat_follow_bottom = true;
425 state.lines.push(ChatLine {
426 text: format!("> {line}"),
427 style: Style::default().fg(Color::Green),
428 });
429
430 match parse_input(&line) {
431 Input::SlashCommand(SlashCommand::Copy) => {
432 copy_chat_with_status(state, CopyScope::All);
433 }
434 Input::SlashCommand(SlashCommand::CopyLast) => {
435 copy_chat_with_status(state, CopyScope::LastAssistant);
436 }
437 Input::SlashCommand(cmd) => {
438 let mut session = runtime.session.lock().await;
439 let mut ctx = SlashContext {
440 session: &mut session,
441 store: &runtime.store,
442 model: &runtime.model,
443 provider_name: &runtime.provider_name,
444 agent: runtime.agent.as_ref(),
445 };
446 match handle_slash(cmd, &mut ctx).await? {
447 SlashAction::Exit => break,
448 SlashAction::Message(text) => state.lines.push(ChatLine {
449 text,
450 style: Style::default().fg(Color::Cyan),
451 }),
452 SlashAction::Continue => {}
453 }
454 state.model_name =
455 runtime.model.read().expect("model lock").clone();
456 state.provider_label =
457 runtime.provider_name.read().expect("provider lock").clone();
458 }
459 Input::UserMessage(msg) => {
460 start_agent_turn(runtime, state, msg);
461 }
462 }
463 }
464 KeyCode::Backspace => {
465 state.input.pop();
466 }
467 KeyCode::Delete => {
468 state.input.pop();
469 }
470 KeyCode::Char(c) => {
471 if key.modifiers.contains(KeyModifiers::CONTROL) {
472 if c == 'h' || c == '\x08' {
473 state.input.pop();
474 }
475 continue;
476 }
477 if c == '\x7f' {
478 state.input.pop();
479 continue;
480 }
481 if !c.is_control() {
482 state.input.push(c);
483 }
484 }
485 _ => {}
486 }
487 }
488 } else if state.running {
489 tokio::task::yield_now().await;
490 }
491 }
492
493 Ok(())
494}
495
496fn start_agent_turn(runtime: &AppRuntime, state: &mut AppState, msg: String) {
497 state.status = t("tui_status_running");
498 state.running = true;
499 state.chat_follow_bottom = true;
500 state.assistant_buf.clear();
501 state.lines.push(ChatLine {
502 text: String::new(),
503 style: Style::default(),
504 });
505
506 let agent = Arc::clone(&runtime.agent);
507 let session = Arc::clone(&runtime.session);
508 let store = Arc::clone(&runtime.store);
509
510 state.turn_task = Some(tokio::spawn(async move {
511 let mut session = session.lock().await;
512 agent.run_turn(&mut session, &msg, &store).await
513 }));
514}
515
516async fn poll_turn_task(state: &mut AppState) {
517 let finished = state
518 .turn_task
519 .as_ref()
520 .is_some_and(|task| task.is_finished());
521 if !finished {
522 return;
523 }
524 let Some(task) = state.turn_task.take() else {
525 return;
526 };
527 match task.await {
528 Ok(Ok(_)) => {}
529 Ok(Err(err)) => {
530 state.lines.push(ChatLine {
531 text: t_fmt("tui_error_prefix", &[("message", &err.to_string())]),
532 style: Style::default().fg(Color::Red),
533 });
534 state.status = t("tui_status_error");
535 state.running = false;
536 }
537 Err(err) => {
538 state.lines.push(ChatLine {
539 text: t_fmt("tui_agent_task_failed", &[("message", &err.to_string())]),
540 style: Style::default().fg(Color::Red),
541 });
542 state.status = t("tui_status_error");
543 state.running = false;
544 }
545 }
546}
547
548fn scroll_chat(state: &mut AppState, delta: i16) {
549 state.chat_follow_bottom = false;
550 if delta < 0 {
551 state.chat_scroll = state.chat_scroll.saturating_sub((-delta) as u16);
552 } else {
553 state.chat_scroll = state.chat_scroll.saturating_add(delta as u16);
554 }
555}
556
557#[derive(Clone, Copy)]
558enum CopyScope {
559 All,
560 LastAssistant,
561}
562
563fn copy_chat_with_status(state: &mut AppState, scope: CopyScope) {
564 let text = match scope {
565 CopyScope::All => chat_text_all(&state.lines),
566 CopyScope::LastAssistant => match last_assistant_text(&state.lines) {
567 Some(text) => text,
568 None => {
569 state.status = t("tui_copy_nothing");
570 return;
571 }
572 },
573 };
574 match copy_to_clipboard(&text) {
575 Ok(()) => {
576 state.status = t_fmt(
577 "tui_copy_ok",
578 &[("count", &text.chars().count().to_string())],
579 );
580 }
581 Err(err) => {
582 state.status = t_fmt("tui_copy_failed", &[("error", &format!("{err:#}"))]);
583 }
584 }
585}
586
587fn chat_text_all(lines: &[ChatLine]) -> String {
588 lines
589 .iter()
590 .map(|line| line.text.as_str())
591 .collect::<Vec<_>>()
592 .join("\n")
593}
594
595fn last_assistant_text(lines: &[ChatLine]) -> Option<String> {
596 lines
597 .iter()
598 .rev()
599 .find(|line| {
600 line.style == Style::default()
601 && !line.text.starts_with("[tool:")
602 && !is_chat_error_line(&line.text)
603 })
604 .map(|line| line.text.clone())
605}
606
607fn apply_slash_completion(state: &mut AppState, hint: &SlashHint) {
608 state.input = hint.command.to_string();
609 if matches!(hint.command, "/model" | "/provider" | "/session resume") {
610 state.input.push(' ');
611 }
612}
613
614fn render_slash_completions(
615 frame: &mut ratatui::Frame,
616 area: Rect,
617 hints: &[&SlashHint],
618 selected: usize,
619) {
620 let items: Vec<ListItem> = hints
621 .iter()
622 .enumerate()
623 .map(|(idx, hint)| {
624 let style = if idx == selected {
625 Style::default()
626 .fg(Color::Black)
627 .bg(Color::Cyan)
628 .add_modifier(Modifier::BOLD)
629 } else {
630 Style::default().fg(Color::Gray)
631 };
632 ListItem::new(Line::from(vec![
633 Span::styled(format!("{:<18}", hint.command), style),
634 Span::styled(t(hint.description_key), style),
635 ]))
636 })
637 .collect();
638 let widget = List::new(items).block(
639 Block::default()
640 .borders(Borders::ALL)
641 .title(t("tui_commands_title"))
642 .style(Style::default().fg(Color::DarkGray)),
643 );
644 frame.render_widget(Clear, area);
645 frame.render_widget(widget, area);
646}
647
648fn wrap_chat_lines(lines: &[ChatLine], area_width: u16) -> Vec<Line<'static>> {
649 let inner = area_width.saturating_sub(2) as usize;
650 let mut rendered = Vec::new();
651 for line in lines {
652 for segment in wrap_text(&line.text, inner.max(1)) {
653 rendered.push(Line::from(Span::styled(segment, line.style)));
654 }
655 }
656 rendered
657}
658
659fn wrap_text(text: &str, width: usize) -> Vec<String> {
660 if text.is_empty() {
661 return vec![String::new()];
662 }
663 let mut lines = Vec::new();
664 let mut current = String::new();
665
666 for ch in text.chars() {
667 if ch == '\n' {
668 lines.push(std::mem::take(&mut current));
669 continue;
670 }
671 if current.chars().count() + 1 > width && !current.is_empty() {
672 lines.push(std::mem::take(&mut current));
673 }
674 current.push(ch);
675 }
676 if !current.is_empty() {
677 lines.push(current);
678 }
679
680 if lines.is_empty() {
681 lines.push(String::new());
682 }
683
684 let mut refined = Vec::new();
686 for line in lines {
687 if line.chars().count() <= width {
688 refined.push(line);
689 continue;
690 }
691 let mut rest: String = line;
692 while !rest.is_empty() {
693 if rest.chars().count() <= width {
694 refined.push(rest);
695 break;
696 }
697 let byte_idx = rest
698 .char_indices()
699 .nth(width)
700 .map(|(i, _)| i)
701 .unwrap_or(rest.len());
702 let mut break_at = byte_idx;
703 if let Some(space) = rest[..byte_idx].rfind(' ') {
704 if space > 0 {
705 break_at = space;
706 }
707 }
708 let (part, remainder) = rest.split_at(break_at);
709 refined.push(part.trim_end().to_string());
710 rest = remainder.trim_start().to_string();
711 }
712 }
713 refined
714}
715
716fn render_approval_modal(
717 frame: &mut ratatui::Frame,
718 area: Rect,
719 request: &codei_tools::ApprovalRequest,
720) {
721 let popup = centered_rect(70, 30, area);
722 frame.render_widget(Clear, popup);
723 let text = t_fmt(
724 "tui_tool_approval_body",
725 &[
726 ("name", &request.tool_name),
727 ("args", &request.arguments.to_string()),
728 ],
729 );
730 let widget = Paragraph::new(text)
731 .wrap(Wrap { trim: false })
732 .style(Style::default().add_modifier(Modifier::BOLD))
733 .block(
734 Block::default()
735 .borders(Borders::ALL)
736 .title(t("tui_tool_approval_title"))
737 .style(Style::default().fg(Color::Yellow)),
738 );
739 frame.render_widget(widget, popup);
740}
741
742fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
743 let popup_layout = Layout::default()
744 .direction(Direction::Vertical)
745 .constraints([
746 Constraint::Percentage((100 - percent_y) / 2),
747 Constraint::Percentage(percent_y),
748 Constraint::Percentage((100 - percent_y) / 2),
749 ])
750 .split(area);
751 Layout::default()
752 .direction(Direction::Horizontal)
753 .constraints([
754 Constraint::Percentage((100 - percent_x) / 2),
755 Constraint::Percentage(percent_x),
756 Constraint::Percentage((100 - percent_x) / 2),
757 ])
758 .split(popup_layout[1])[1]
759}
760
761fn flush_assistant(lines: &mut [ChatLine], assistant_buf: &mut String) {
762 if !assistant_buf.is_empty() {
763 assistant_buf.clear();
764 }
765 let _ = lines;
766}
767
768fn truncate(s: &str, max: usize) -> String {
769 if s.len() <= max {
770 s.to_string()
771 } else {
772 format!("{}...", &s[..max])
773 }
774}
775
776fn is_chat_error_line(text: &str) -> bool {
777 text.starts_with("Error:") || text.starts_with("错误:")
778}