Skip to main content

codei_tui/
app.rs

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    // Word-aware second pass: try to break at spaces for long lines.
1089    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: usize) -> String {
1203    if s.len() <= max {
1204        s.to_string()
1205    } else {
1206        format!("{}...", &s[..max])
1207    }
1208}
1209
1210fn is_chat_error_line(text: &str) -> bool {
1211    text.starts_with("Error:") || text.starts_with("错误:")
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use super::*;
1217
1218    #[test]
1219    fn normalize_line_endings_unifies_crlf_and_cr() {
1220        assert_eq!(normalize_line_endings("a\r\nb\rc"), "a\nb\nc");
1221    }
1222
1223    #[test]
1224    fn input_display_lines_preserves_trailing_newline() {
1225        let lines = input_display_lines("a\nb\n");
1226        assert_eq!(lines.len(), 3);
1227        assert_eq!(lines[2], Line::from(""));
1228    }
1229
1230    #[test]
1231    fn key_inserts_newline_for_alt_enter_and_ctrl_enter() {
1232        assert!(key_inserts_newline(&KeyEvent::new(
1233            KeyCode::Enter,
1234            KeyModifiers::ALT,
1235        )));
1236        assert!(key_inserts_newline(&KeyEvent::new(
1237            KeyCode::Enter,
1238            KeyModifiers::CONTROL,
1239        )));
1240        assert!(!key_inserts_newline(&KeyEvent::new(
1241            KeyCode::Enter,
1242            KeyModifiers::empty(),
1243        )));
1244    }
1245
1246    #[test]
1247    fn input_history_browses_submitted_messages() {
1248        let mut history = InputHistory::new();
1249        history.push("first".into());
1250        history.push("second".into());
1251
1252        assert_eq!(history.browse_older(""), as_deref("second"));
1253        assert_eq!(history.browse_older("ignored"), as_deref("first"));
1254        assert_eq!(history.browse_older("ignored"), None);
1255        assert_eq!(history.browse_newer(), as_deref("second"));
1256        assert_eq!(history.browse_newer(), Some(String::new()));
1257    }
1258
1259    #[test]
1260    fn input_history_restores_draft_after_browse() {
1261        let mut history = InputHistory::new();
1262        history.push("old".into());
1263        assert_eq!(history.browse_older("draft text"), as_deref("old"));
1264        assert_eq!(history.browse_newer(), Some("draft text".into()));
1265    }
1266
1267    fn as_deref(value: &str) -> Option<String> {
1268        Some(value.to_string())
1269    }
1270
1271    #[test]
1272    fn input_cursor_pos_tracks_byte_offset() {
1273        use ratatui::layout::Rect;
1274
1275        let area = Rect::new(0, 0, 40, 5);
1276        let input = "ab\ncd";
1277        let (x, y) = input_cursor_pos(input, 4, area);
1278        assert_eq!(y, 2);
1279        assert_eq!(x, 2);
1280    }
1281
1282    #[test]
1283    fn prev_char_boundary_skips_utf8() {
1284        let text = "aéb";
1285        let end = text.len();
1286        let mid = prev_char_boundary(text, end);
1287        assert_eq!(&text[..mid], "aé");
1288    }
1289}