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_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    // Word-aware second pass: try to break at spaces for long lines.
685    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}