vtcode_core/ui/
iocraft.rs

1use anyhow::{Context, Result};
2use iocraft::prelude::*;
3use std::time::{Duration, Instant};
4use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
5
6const ESCAPE_DOUBLE_MS: u64 = 750;
7
8#[derive(Clone, Default)]
9pub struct IocraftTextStyle {
10    pub color: Option<Color>,
11    pub weight: Weight,
12    pub italic: bool,
13}
14
15impl IocraftTextStyle {
16    pub fn merge_color(mut self, fallback: Option<Color>) -> Self {
17        if self.color.is_none() {
18            self.color = fallback;
19        }
20        self
21    }
22}
23
24#[derive(Clone, Default)]
25pub struct IocraftSegment {
26    pub text: String,
27    pub style: IocraftTextStyle,
28}
29
30#[derive(Clone, Default)]
31struct StyledLine {
32    segments: Vec<IocraftSegment>,
33}
34
35impl StyledLine {
36    fn push_segment(&mut self, segment: IocraftSegment) {
37        if segment.text.is_empty() {
38            return;
39        }
40        self.segments.push(segment);
41    }
42}
43
44#[derive(Clone)]
45pub struct IocraftTheme {
46    pub background: Option<Color>,
47    pub foreground: Option<Color>,
48    pub primary: Option<Color>,
49    pub secondary: Option<Color>,
50}
51
52impl Default for IocraftTheme {
53    fn default() -> Self {
54        Self {
55            background: None,
56            foreground: None,
57            primary: None,
58            secondary: None,
59        }
60    }
61}
62
63pub enum IocraftCommand {
64    AppendLine {
65        segments: Vec<IocraftSegment>,
66    },
67    Inline {
68        segment: IocraftSegment,
69    },
70    SetPrompt {
71        prefix: String,
72        style: IocraftTextStyle,
73    },
74    SetPlaceholder {
75        hint: Option<String>,
76    },
77    SetTheme {
78        theme: IocraftTheme,
79    },
80    Shutdown,
81}
82
83#[derive(Debug, Clone)]
84pub enum IocraftEvent {
85    Submit(String),
86    Cancel,
87    Exit,
88    Interrupt,
89    ScrollLineUp,
90    ScrollLineDown,
91    ScrollPageUp,
92    ScrollPageDown,
93}
94
95#[derive(Clone)]
96pub struct IocraftHandle {
97    sender: UnboundedSender<IocraftCommand>,
98}
99
100impl IocraftHandle {
101    pub fn append_line(&self, segments: Vec<IocraftSegment>) {
102        if segments.is_empty() {
103            let _ = self.sender.send(IocraftCommand::AppendLine {
104                segments: vec![IocraftSegment::default()],
105            });
106        } else {
107            let _ = self.sender.send(IocraftCommand::AppendLine { segments });
108        }
109    }
110
111    pub fn inline(&self, segment: IocraftSegment) {
112        let _ = self.sender.send(IocraftCommand::Inline { segment });
113    }
114
115    pub fn set_prompt(&self, prefix: String, style: IocraftTextStyle) {
116        let _ = self
117            .sender
118            .send(IocraftCommand::SetPrompt { prefix, style });
119    }
120
121    pub fn set_placeholder(&self, hint: Option<String>) {
122        let _ = self.sender.send(IocraftCommand::SetPlaceholder { hint });
123    }
124
125    pub fn set_theme(&self, theme: IocraftTheme) {
126        let _ = self.sender.send(IocraftCommand::SetTheme { theme });
127    }
128
129    pub fn shutdown(&self) {
130        let _ = self.sender.send(IocraftCommand::Shutdown);
131    }
132}
133
134pub struct IocraftSession {
135    pub handle: IocraftHandle,
136    pub events: UnboundedReceiver<IocraftEvent>,
137}
138
139pub fn spawn_session(theme: IocraftTheme, placeholder: Option<String>) -> Result<IocraftSession> {
140    let (command_tx, command_rx) = mpsc::unbounded_channel();
141    let (event_tx, event_rx) = mpsc::unbounded_channel();
142
143    tokio::spawn(async move {
144        if let Err(err) = run_iocraft(command_rx, event_tx, theme, placeholder).await {
145            tracing::error!(error = ?err, "iocraft session terminated unexpectedly");
146        }
147    });
148
149    Ok(IocraftSession {
150        handle: IocraftHandle { sender: command_tx },
151        events: event_rx,
152    })
153}
154
155async fn run_iocraft(
156    commands: UnboundedReceiver<IocraftCommand>,
157    events: UnboundedSender<IocraftEvent>,
158    theme: IocraftTheme,
159    placeholder: Option<String>,
160) -> Result<()> {
161    element! {
162        SessionRoot(
163            commands: commands,
164            events: events,
165            theme: theme,
166            placeholder: placeholder,
167        )
168    }
169    .render_loop()
170    .await
171    .context("iocraft render loop failed")
172}
173
174#[derive(Default, Props)]
175struct SessionRootProps {
176    commands: Option<UnboundedReceiver<IocraftCommand>>,
177    events: Option<UnboundedSender<IocraftEvent>>,
178    theme: IocraftTheme,
179    placeholder: Option<String>,
180}
181
182#[component]
183fn SessionRoot(props: &mut SessionRootProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
184    let mut system = hooks.use_context_mut::<SystemContext>();
185    let lines = hooks.use_state(Vec::<StyledLine>::default);
186    let current_line = hooks.use_state(StyledLine::default);
187    let current_active = hooks.use_state(|| false);
188    let prompt_prefix = hooks.use_state(|| "❯ ".to_string());
189    let prompt_style = hooks.use_state(IocraftTextStyle::default);
190    let input_value = hooks.use_state(|| String::new());
191    let placeholder_hint = hooks.use_state(|| props.placeholder.clone().unwrap_or_default());
192    let show_placeholder = hooks.use_state(|| props.placeholder.is_some());
193    let should_exit = hooks.use_state(|| false);
194    let theme_state = hooks.use_state(|| props.theme.clone());
195    let command_state = hooks.use_state(|| props.commands.take());
196
197    hooks.use_future({
198        let mut command_slot = command_state;
199        let mut lines_state = lines;
200        let mut current_line_state = current_line;
201        let mut current_active_state = current_active;
202        let mut prompt_prefix_state = prompt_prefix;
203        let mut prompt_style_state = prompt_style;
204        let mut placeholder_state = placeholder_hint;
205        let mut placeholder_visible_state = show_placeholder;
206        let mut exit_state = should_exit;
207        async move {
208            let receiver = {
209                let mut guard = command_slot
210                    .try_write()
211                    .expect("iocraft commands receiver missing");
212                guard.take()
213            };
214
215            let Some(mut rx) = receiver else {
216                return;
217            };
218
219            while let Some(cmd) = rx.recv().await {
220                match cmd {
221                    IocraftCommand::AppendLine { segments } => {
222                        let was_active = current_active_state.get();
223                        flush_current_line(
224                            &mut current_line_state,
225                            &mut current_active_state,
226                            &mut lines_state,
227                            was_active,
228                        );
229                        if let Some(mut lines) = lines_state.try_write() {
230                            lines.push(StyledLine { segments });
231                        }
232                    }
233                    IocraftCommand::Inline { segment } => {
234                        append_inline_segment(
235                            &mut current_line_state,
236                            &mut current_active_state,
237                            &mut lines_state,
238                            segment,
239                        );
240                    }
241                    IocraftCommand::SetPrompt { prefix, style } => {
242                        prompt_prefix_state.set(prefix);
243                        prompt_style_state.set(style);
244                    }
245                    IocraftCommand::SetPlaceholder { hint } => {
246                        placeholder_state.set(hint.clone().unwrap_or_default());
247                        placeholder_visible_state.set(hint.is_some());
248                    }
249                    IocraftCommand::SetTheme { theme } => {
250                        let mut theme_handle = theme_state;
251                        theme_handle.set(theme);
252                    }
253                    IocraftCommand::Shutdown => {
254                        exit_state.set(true);
255                        break;
256                    }
257                }
258            }
259        }
260    });
261
262    if should_exit.get() {
263        system.exit();
264    }
265
266    let events_tx = props.events.clone().expect("iocraft events sender missing");
267    let mut last_escape = hooks.use_state(|| None::<Instant>);
268    let mut placeholder_toggle = show_placeholder;
269
270    hooks.use_terminal_events(move |event| {
271        if let TerminalEvent::Key(KeyEvent {
272            code,
273            kind,
274            modifiers,
275            ..
276        }) = event
277        {
278            if kind == KeyEventKind::Release {
279                return;
280            }
281
282            match code {
283                KeyCode::Enter => {
284                    let text = input_value.to_string();
285                    let mut input_handle = input_value;
286                    input_handle.set(String::new());
287                    last_escape.set(None);
288                    placeholder_toggle.set(false);
289                    let _ = events_tx.send(IocraftEvent::Submit(text));
290                }
291                KeyCode::Esc => {
292                    let now = Instant::now();
293                    if last_escape
294                        .get()
295                        .and_then(|prev| now.checked_duration_since(prev))
296                        .map(|elapsed| elapsed <= Duration::from_millis(ESCAPE_DOUBLE_MS))
297                        .unwrap_or(false)
298                    {
299                        let _ = events_tx.send(IocraftEvent::Exit);
300                        let mut exit_flag = should_exit;
301                        exit_flag.set(true);
302                    } else {
303                        last_escape.set(Some(now));
304                        let _ = events_tx.send(IocraftEvent::Cancel);
305                    }
306                }
307                KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
308                    let _ = events_tx.send(IocraftEvent::Interrupt);
309                    let mut exit_flag = should_exit;
310                    exit_flag.set(true);
311                }
312                KeyCode::Up => {
313                    let _ = events_tx.send(IocraftEvent::ScrollLineUp);
314                }
315                KeyCode::Down => {
316                    let _ = events_tx.send(IocraftEvent::ScrollLineDown);
317                }
318                KeyCode::PageUp => {
319                    let _ = events_tx.send(IocraftEvent::ScrollPageUp);
320                }
321                KeyCode::PageDown => {
322                    let _ = events_tx.send(IocraftEvent::ScrollPageDown);
323                }
324                KeyCode::Char('k')
325                    if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
326                {
327                    let _ = events_tx.send(IocraftEvent::ScrollLineUp);
328                }
329                KeyCode::Char('j')
330                    if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
331                {
332                    let _ = events_tx.send(IocraftEvent::ScrollLineDown);
333                }
334                _ => {}
335            }
336        }
337    });
338
339    let mut transcript_lines = lines.read().clone();
340    if let Some(current) = current_line.try_read() {
341        if current_active.get() && (!current.segments.is_empty()) {
342            transcript_lines.push(current.clone());
343        }
344    }
345
346    let prompt_prefix_value = prompt_prefix.to_string();
347    let prompt_style_value = prompt_style.read().clone();
348    let input_value_string = input_value.to_string();
349    let placeholder_text = placeholder_hint.to_string();
350    let placeholder_visible = show_placeholder.get() && !placeholder_text.is_empty();
351
352    let transcript_rows = transcript_lines.into_iter().map(|line| {
353        element! {
354            View(flex_direction: FlexDirection::Row) {
355                #(line
356                    .segments
357                    .into_iter()
358                    .map(|segment| element! {
359                        Text(
360                            content: segment.text,
361                            color: segment.style.color,
362                            weight: segment.style.weight,
363                            italic: segment.style.italic,
364                            wrap: TextWrap::NoWrap,
365                        )
366                    }))
367            }
368        }
369    });
370
371    let theme_value = theme_state.read().clone();
372
373    let background = theme_value
374        .background
375        .unwrap_or(Color::Rgb { r: 0, g: 0, b: 0 });
376    let foreground = theme_value.foreground.unwrap_or(Color::White);
377
378    let placeholder_color = theme_value.secondary.or(Some(foreground));
379    let placeholder_element = placeholder_visible.then(|| {
380        element! {
381            Text(
382                content: placeholder_text.clone(),
383                color: placeholder_color,
384                italic: true,
385            )
386        }
387    });
388    let input_value_state = input_value;
389
390    element! {
391        View(
392            flex_direction: FlexDirection::Column,
393            padding: 1u16,
394            gap: 1u16,
395            background_color: background,
396        ) {
397            View(
398                flex_direction: FlexDirection::Column,
399                flex_grow: 1.0,
400                gap: 0u16,
401                overflow: Overflow::Hidden,
402            ) {
403                #(transcript_rows)
404            }
405            View(flex_direction: FlexDirection::Column, gap: 1u16) {
406                View(
407                    flex_direction: FlexDirection::Row,
408                    align_items: AlignItems::Center,
409                    gap: 1u16,
410                ) {
411                    Text(
412                        content: prompt_prefix_value.clone(),
413                        color: prompt_style_value.color.or(theme_value.secondary),
414                        weight: prompt_style_value.weight,
415                        italic: prompt_style_value.italic,
416                        wrap: TextWrap::NoWrap,
417                    )
418                    TextInput(
419                        has_focus: true,
420                        value: input_value_string.clone(),
421                        on_change: move |value| {
422                            let mut handle = input_value_state;
423                            handle.set(value);
424                        },
425                        color: theme_value.foreground,
426                    )
427                }
428                #(placeholder_element.into_iter())
429            }
430        }
431    }
432}
433
434fn flush_current_line(
435    current_line: &mut State<StyledLine>,
436    current_active: &mut State<bool>,
437    lines_state: &mut State<Vec<StyledLine>>,
438    force: bool,
439) {
440    if !force && !current_active.get() {
441        return;
442    }
443
444    if let Some(cur) = current_line.try_read() {
445        if !cur.segments.is_empty() || force {
446            if let Some(mut lines) = lines_state.try_write() {
447                lines.push(cur.clone());
448            }
449        }
450    }
451
452    if let Some(mut cur) = current_line.try_write() {
453        cur.segments.clear();
454    }
455    current_active.set(false);
456}
457
458fn append_inline_segment(
459    current_line: &mut State<StyledLine>,
460    current_active: &mut State<bool>,
461    lines_state: &mut State<Vec<StyledLine>>,
462    segment: IocraftSegment,
463) {
464    let text = segment.text;
465    let style = segment.style;
466
467    if text.is_empty() {
468        return;
469    }
470
471    let mut parts = text.split('\n').peekable();
472    let ends_with_newline = text.ends_with('\n');
473
474    while let Some(part) = parts.next() {
475        if !part.is_empty() {
476            if let Some(mut cur) = current_line.try_write() {
477                cur.push_segment(IocraftSegment {
478                    text: part.to_string(),
479                    style: style.clone(),
480                });
481            }
482            current_active.set(true);
483        }
484
485        if parts.peek().is_some() {
486            flush_current_line(current_line, current_active, lines_state, true);
487        }
488    }
489
490    if ends_with_newline {
491        flush_current_line(current_line, current_active, lines_state, true);
492    }
493}
494
495pub fn convert_style(style: anstyle::Style) -> IocraftTextStyle {
496    let color = style.get_fg_color().and_then(|color| convert_color(color));
497    let effects = style.get_effects();
498    let weight = if effects.contains(anstyle::Effects::BOLD) {
499        Weight::Bold
500    } else {
501        Weight::Normal
502    };
503    let italic = effects.contains(anstyle::Effects::ITALIC);
504
505    IocraftTextStyle {
506        color,
507        weight,
508        italic,
509    }
510}
511
512pub fn convert_color(color: anstyle::Color) -> Option<Color> {
513    match color {
514        anstyle::Color::Ansi(ansi) => Some(match ansi {
515            anstyle::AnsiColor::Black => Color::Black,
516            anstyle::AnsiColor::Red => Color::DarkRed,
517            anstyle::AnsiColor::Green => Color::DarkGreen,
518            anstyle::AnsiColor::Yellow => Color::DarkYellow,
519            anstyle::AnsiColor::Blue => Color::DarkBlue,
520            anstyle::AnsiColor::Magenta => Color::DarkMagenta,
521            anstyle::AnsiColor::Cyan => Color::DarkCyan,
522            anstyle::AnsiColor::White => Color::Grey,
523            anstyle::AnsiColor::BrightBlack => Color::DarkGrey,
524            anstyle::AnsiColor::BrightRed => Color::Red,
525            anstyle::AnsiColor::BrightGreen => Color::Green,
526            anstyle::AnsiColor::BrightYellow => Color::Yellow,
527            anstyle::AnsiColor::BrightBlue => Color::Blue,
528            anstyle::AnsiColor::BrightMagenta => Color::Magenta,
529            anstyle::AnsiColor::BrightCyan => Color::Cyan,
530            anstyle::AnsiColor::BrightWhite => Color::White,
531        }),
532        anstyle::Color::Ansi256(value) => Some(Color::AnsiValue(value.index())),
533        anstyle::Color::Rgb(rgb) => Some(Color::Rgb {
534            r: rgb.r(),
535            g: rgb.g(),
536            b: rgb.b(),
537        }),
538    }
539}
540
541pub fn theme_from_styles(styles: &crate::ui::theme::ThemeStyles) -> IocraftTheme {
542    IocraftTheme {
543        background: convert_color(styles.background),
544        foreground: convert_style(styles.output).color,
545        primary: convert_style(styles.primary).color,
546        secondary: convert_style(styles.secondary).color,
547    }
548}