Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chat

Group: Content

Code

/// Cadence for the "thinking" spinner's animation frame while
/// `thinking_ticks > 0` — see [`InteractiveState::tick`].
const THINKING_TICK_INTERVAL: Duration = Duration::from_millis(100);

/// Canned (prompt, markdown-reply) pairs cycled through by the
/// "Markdown transcript" variant.
const EXCHANGES: &[(&str, &str)] = &[
    (
        "How do I convert markdown to styled text?",
        "\
# Markdown \u{2192} StyledText

Call `render_markdown_to_styled(input, &theme)`. It returns:

- `lines` — one **StyledText** per source line
- `line_text` — plain text, for hit-tests and search

> `snake_case` identifiers like render_markdown_to_styled stay upright.",
    ),
    (
        "What markdown does it support today?",
        "\
Supported constructs

Inline **bold**, *italic*, and `inline_code` all render with correct spans.

1. Numbered items render with a styled marker
2. Both dash and asterisk bullet syntax work

> This is a blockquote with a `\u{2502}` left-bar decoration.",
    ),
];

/// Variant 0 state: interactive session — echoes the user's message back
/// as a simulated assistant reply after a short "thinking" delay.
struct InteractiveState {
    controller: ChatController,
    turns: Vec<ChatTurn>,
    thinking_ticks: usize,
    pending_reply: Option<String>,
    spinner_frame: usize,
}

impl InteractiveState {
    fn new() -> Self {
        let mut controller = ChatController::new("gallery:chat:interactive");
        controller.set_status(StyledText::plain(
            "Enter to send, Ctrl+C to quit demo input",
        ));
        controller.set_model_label("claude-opus-4-5");
        controller.set_submit_on_enter(true);
        controller.set_hint(Some(StyledText::plain(
            "Enter to send \u{b7} Shift+Enter for a newline",
        )));
        Self {
            controller,
            turns: Vec::new(),
            thinking_ticks: 0,
            pending_reply: None,
            spinner_frame: 0,
        }
    }

    fn sync(&mut self) {
        self.controller.set_transcript(self.turns.clone());
        self.controller.set_busy(self.thinking_ticks > 0);
    }

    fn handle(&mut self, event: &UiEvent, backend: &mut dyn Backend, area: Rect) -> Reaction {
        let ev = self.controller.handle(event, backend, area);
        match ev {
            ChatControllerEvent::Submit { text } => {
                self.turns.push(ChatTurn {
                    role: ChatRole::User,
                    text: StyledText::colored(text.clone(), Color::rgb(220, 220, 220)),
                    timestamp_unix: None,
                    line_scales: Vec::new(),
                });
                self.controller.clear_input();
                self.pending_reply = Some(format!("Echo: {text}"));
                self.thinking_ticks = 5;
                backend.request_frame_in(THINKING_TICK_INTERVAL);
                self.sync();
                Reaction::Redraw
            }
            ChatControllerEvent::StopRequested => {
                self.thinking_ticks = 0;
                self.pending_reply = None;
                self.sync();
                Reaction::Redraw
            }
            ChatControllerEvent::TurnClicked {
                turn_idx,
                row_in_turn: 0,
            } => {
                self.controller.toggle_turn_collapsed(turn_idx);
                self.sync();
                Reaction::Redraw
            }
            ChatControllerEvent::Consumed => Reaction::Redraw,
            _ => {
                if matches!(event, UiEvent::WindowResized { .. }) {
                    Reaction::Redraw
                } else {
                    Reaction::Continue
                }
            }
        }
    }

    fn tick(&mut self, backend: &mut dyn Backend) -> Reaction {
        if self.thinking_ticks == 0 {
            return Reaction::Continue;
        }
        self.thinking_ticks -= 1;
        self.spinner_frame = self.spinner_frame.wrapping_add(1);
        self.controller.set_spinner_frame(self.spinner_frame);
        if self.thinking_ticks == 0 {
            if let Some(reply) = self.pending_reply.take() {
                self.turns.push(ChatTurn {
                    role: ChatRole::Assistant,
                    text: StyledText::colored(reply, Color::rgb(180, 230, 180)),
                    timestamp_unix: None,
                    line_scales: Vec::new(),
                });
            }
        } else {
            backend.request_frame_in(THINKING_TICK_INTERVAL);
        }
        self.sync();
        Reaction::Redraw
    }
}

/// Variant 1 state: canned markdown exchanges pushed via
/// `push_turn_markdown`.
struct TranscriptState {
    controller: ChatController,
    next_exchange: usize,
}

impl TranscriptState {
    fn new() -> Self {
        let mut controller = ChatController::new("gallery:chat:transcript");
        controller.set_status(StyledText::plain(
            "AI transcript \u{b7} Ctrl+S to send \u{b7} PgUp/PgDn scroll",
        ));
        controller.set_scrollbar_width(Some(1.0));
        controller.push_turn(
            ChatRole::System,
            StyledText::plain("Connected. Press Ctrl+S or Alt+Enter to send the next prompt."),
        );
        Self {
            controller,
            next_exchange: 0,
        }
    }

    fn send_next(&mut self) {
        let (prompt, reply_md) = EXCHANGES[self.next_exchange % EXCHANGES.len()];
        self.next_exchange += 1;
        self.controller
            .push_turn(ChatRole::User, StyledText::plain(prompt));
        let theme = Theme::default();
        self.controller
            .push_turn_markdown(ChatRole::Assistant, reply_md, &theme);
    }

    /// Total turns pushed so far: the one `System` turn from [`Self::new`]
    /// plus a (user, assistant) pair per completed [`Self::send_next`]
    /// call. `ChatController` has no public turn-count accessor to read
    /// this back from directly, so it's derived here instead of (wrongly)
    /// reporting `transcript_scroll_top()` as if it were one.
    fn turn_count(&self) -> usize {
        1 + self.next_exchange * 2
    }

    fn handle(&mut self, event: &UiEvent, backend: &mut dyn Backend, area: Rect) -> Reaction {
        match self.controller.handle(event, backend, area) {
            ChatControllerEvent::Submit { .. } => {
                self.send_next();
                Reaction::Redraw
            }
            ChatControllerEvent::Consumed => Reaction::Redraw,
            _ => Reaction::Continue,
        }
    }
}

pub struct ChatDemo {
    interactive: InteractiveState,
    transcript: TranscriptState,
}

impl ChatDemo {
    pub fn new() -> Self {
        Self {
            interactive: InteractiveState::new(),
            transcript: TranscriptState::new(),
        }
    }
}

impl Default for ChatDemo {
    fn default() -> Self {
        Self::new()
    }
}

impl Demo for ChatDemo {
    fn name(&self) -> &'static str {
        "Chat"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Interactive", "Markdown transcript"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        match variant {
            1 => self.transcript.controller.render(backend, area),
            _ => self.interactive.controller.render(backend, area),
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match variant {
            1 => self.transcript.handle(event, backend, area),
            _ => self.interactive.handle(event, backend, area),
        }
    }

    fn tick(&mut self, variant: usize, backend: &mut dyn Backend) -> Reaction {
        match variant {
            1 => Reaction::Continue,
            _ => self.interactive.tick(backend),
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        match variant {
            1 => serde_json::json!({
                "turns": self.transcript.turn_count(),
                "next_exchange": self.transcript.next_exchange,
            }),
            _ => serde_json::json!({
                "turns": self.interactive.turns.len(),
                "thinking_ticks": self.interactive.thinking_ticks,
                "input": self.interactive.controller.input_text(),
            }),
        }
    }
}

Backends

VariantTUIGTKmacOSWindows
Interactive
Markdown transcript