Skip to main content

rig_tui/
lib.rs

1//! The terminal view: the front ([`FRONT`]) of a run where someone sits at
2//! the terminal ([`Invoked::interactive`](rig_harness::prelude::Invoked::interactive)).
3//! It reads agent components, keeps its own state in resources and
4//! components, and sends the same requests any other view would. It never
5//! owns the app's loop: a thread reads the terminal and wakes the loop,
6//! `PreUpdate` handles input one key at a time ([`TuiKeys`]), and
7//! `PostUpdate` lays out and draws a frame only when something drawn
8//! changed ([`TuiSystems`]).
9//!
10//! [`TuiPlugin`] is [`TuiViewPlugin`] plus [`DefaultScreenPlugin`]. Where a
11//! plugin hooks in:
12//!
13//! - **Panels:** [`TuiPanel`], drawn in [`TuiSystems::Draw`] ([`panel`]);
14//!   [`RequestRedraw`] for a frame, [`TerminalSize`] for the screen.
15//! - **Transcripts:** [`TranscriptPane`] over the [`ShownAgent`], another
16//!   agent or a conversation, with its [`TranscriptView`], [`ScrollTo`],
17//!   [`DrawnTranscript`] and [`TranscriptLayout`] ([`pane`]); [`ShowAgent`]
18//!   shows another agent.
19//! - **Looks:** [`AppToolRenderersExt`] for tool calls, notes, content
20//!   blocks, asides, message text and decorators.
21//! - **Status line:** [`StatusItems`], [`AppStatus`] ([`status`]).
22//! - **Keys:** [`KeyBinding`], [`KeyFocus`] ([`keys`]); [`PendingInput`]
23//!   in [`InputSystems::Filter`] for keys before any binding, with the
24//!   [`InputContext`].
25//! - **Input:** [`Editor`], [`MainInput`], [`PromptHistory`],
26//!   [`CompletionSource`].
27//! - **The terminal:** [`RunInTerminal`], [`TerminalWrite`],
28//!   [`CopyToClipboard`], [`TuiFrame`], [`TerminalFocus`]; a test draws to a
29//!   [`TuiTestScreen`].
30//! - **Same-looking text:** [`markdown`], [`wrap`], [`diff`], [`highlight`],
31//!   [`text_rows`], and
32//!   the colors of what each thing is and the flat surfaces of panels,
33//!   [`Theme`].
34//!
35//! The view shows every plugin's pickers and every agent's questions, one
36//! at a time ([`PickerInput`]). All of this exists in every run, also under
37//! another front such as `--print`, which never draws a frame. [`ratatui`]
38//! and [`crossterm`] are re-exported so a plugin uses the same versions.
39
40mod clipboard;
41mod complete;
42pub mod diff;
43mod editor;
44pub mod highlight;
45mod history;
46mod input;
47mod jsonl;
48pub mod keys;
49pub mod markdown;
50pub mod pane;
51pub mod panel;
52mod pick;
53mod render;
54mod renderers;
55mod screen;
56pub mod status;
57mod terminal;
58mod theme;
59mod transcript;
60mod turns;
61mod view;
62pub mod wrap;
63
64use bevy_app::prelude::*;
65use bevy_ecs::prelude::*;
66use bevy_ecs::schedule::common_conditions::any_match_filter;
67use rig_ecs::stage::AgentLoop;
68use rig_harness::prelude::launcher::BUILD_ORIGIN;
69use rig_harness::prelude::{Answerer, AppCliExt, AppKeepsExt, Front, RigHome, ShownAgent};
70
71pub use complete::{Candidate, Completion, CompletionKind, CompletionSource, CompletionTrigger};
72pub use crossterm;
73pub use editor::{Editor, MainEditor, MainInput};
74pub use history::{HistorySystems, PromptHistory, Sent};
75pub use input::{InputContext, InputSystems, KeyInput, PendingInput, TuiKeys};
76pub use keys::{
77    AppKeyBindingsExt, InputAction, KeyBinding, KeyFocus, KeyPress, KeyPressed, KeyScope, Keys,
78    run_command,
79};
80pub use pane::{MainPane, MainTranscript, PaneKeys, TranscriptPane, TranscriptSource};
81pub use panel::{
82    PanelCanvas, Placement, RedrawArea, RequestRedraw, TerminalSize, TuiPanel, TuiSystems,
83};
84pub use pick::{ChoiceAction, ChoiceKeys, InlineStrip, PickerInput};
85pub use ratatui;
86pub use render::{TranscriptLayout, TuiFrame};
87pub use renderers::{
88    AppToolRenderersExt, AsideRenderer, AsideView, CallDecorator, CallState, ChildCall,
89    ContentBlock, ContentKind, ContentRenderer, ContentView, DecorateMessage, DecoratedMessage,
90    EntryRenderer, EntryView, MessageDecorator, MessageView, OUTPUT_INDENT, RenderAside,
91    RenderContent, RenderEntry, RenderToolCall, Role, ToolCallView, ToolRenderer, text_rows,
92};
93pub use screen::DefaultScreenPlugin;
94pub use status::{AppStatus, Side, StatusItem, StatusItems, StatusLine, StatusSystems, Tone};
95pub use terminal::{
96    CopyToClipboard, RunInTerminal, TerminalExit, TerminalFocus, TerminalWrite, TuiTestScreen,
97};
98pub use theme::Theme;
99pub use transcript::PastEffects;
100pub use view::{
101    DrawnLine, DrawnRow, DrawnTranscript, Highlight, ItemRow, LineSpan, MarkIn, Notices, RowWrap,
102    ScrollTo, ShowAgent, ShownNotice, TextMark, TranscriptItem, TranscriptSlot, TranscriptView,
103};
104
105/// The terminal view's name as a front.
106pub const FRONT: &str = "tui";
107
108/// The terminal view with its default screen ([`TuiViewPlugin`] plus
109/// [`DefaultScreenPlugin`]). It owns the terminal when it is the run's
110/// front: on an interactive terminal, unless another front's option was given.
111#[derive(Default)]
112pub struct TuiPlugin;
113
114impl Plugin for TuiPlugin {
115    fn build(&self, app: &mut App) {
116        app.add_plugins((TuiViewPlugin, DefaultScreenPlugin));
117    }
118}
119
120/// The terminal view without a screen: the terminal, the keys, the frame
121/// and the widgets' systems, which draw whatever widgets are spawned. The
122/// [`TuiPlugin`] adds it with the [`DefaultScreenPlugin`]; an app that
123/// builds its own screen adds it alone.
124#[derive(Default)]
125pub struct TuiViewPlugin;
126
127impl Plugin for TuiViewPlugin {
128    fn build(&self, app: &mut App) {
129        // What other plugins use is there in any run; without the
130        // terminal, no frame is ever due.
131        status::add(app);
132        // Spawned in `build`, so a plugin's `finish` may disable one.
133        keys::add_built_in(app);
134        app.add_entry_renderer(BUILD_ORIGIN, build_failed);
135        // The notices shown and the unsent draft, in the session's files.
136        app.keeps_files("tui");
137        app.init_resource::<render::FrameLayout>()
138            .init_resource::<TuiFrame>()
139            .init_resource::<Notices>()
140            .init_resource::<transcript::CallFacts>()
141            .init_resource::<TerminalSize>()
142            .init_resource::<Theme>()
143            .init_resource::<PromptHistory>()
144            .init_resource::<TerminalFocus>()
145            .init_resource::<PendingInput>()
146            .init_resource::<InputContext>()
147            .add_message::<RequestRedraw>()
148            .add_message::<RedrawArea>()
149            .add_message::<Sent>()
150            .add_message::<TerminalWrite>()
151            .add_message::<CopyToClipboard>()
152            .add_message::<PastEffects>()
153            .configure_sets(
154                TuiKeys,
155                (InputSystems::Filter, InputSystems::Dispatch).chain(),
156            )
157            .add_systems(PreUpdate, input::run_keys)
158            .add_systems(
159                TuiKeys,
160                input::clear_events
161                    .in_set(InputSystems::Dispatch)
162                    .run_if(not(resource_exists::<view::TuiView>))
163                    .ambiguous_with(input::read_input),
164            )
165            .configure_sets(
166                PostUpdate,
167                (
168                    TuiSystems::Prepare,
169                    TuiSystems::Layout,
170                    TuiSystems::Fit,
171                    TuiSystems::Draw.run_if(render::frame_due),
172                    TuiSystems::Render.run_if(render::frame_due),
173                )
174                    .chain()
175                    .after(StatusSystems::Edit),
176            )
177            .configure_sets(
178                Update,
179                (HistorySystems::Record, HistorySystems::Save).chain(),
180            );
181        app.add_front(Front::new(FRONT).on_terminal());
182    }
183
184    /// Owns the terminal when the run is the terminal view's: any other
185    /// front, such as a print run, has stdout for its own output.
186    fn finish(&self, app: &mut App) {
187        if !app.runs_front(FRONT) {
188            return;
189        }
190        add_view(app);
191        app.add_systems(PreStartup, history::load)
192            .add_systems(Startup, terminal::open_terminal)
193            .add_systems(
194                Last,
195                terminal::keep_screen_on_reload.in_set(bevy_app::OnAppExitSystems),
196            );
197    }
198}
199
200/// Adds the view without a terminal: its input, keys, pickers, questions
201/// and the transcript it lays out, driven by the keys put in
202/// [`PendingInput`] and drawn only to a [`TuiTestScreen`]. The
203/// [`TuiViewPlugin`] adds it in `finish` when the run's front is the
204/// terminal view, then opens the terminal; a test adds it once, after the
205/// plugin, and inserts a [`TuiTestScreen`] instead of a terminal.
206pub fn add_view(app: &mut App) {
207    app.world_mut()
208        .spawn((Name::new("answerer:tui"), Answerer::default()));
209    app.add_message::<rig_harness::watch::FileChanges>()
210        .init_resource::<RigHome>()
211        .init_resource::<ShownAgent>()
212        .init_resource::<view::TuiView>()
213        .init_resource::<complete::FileIndex>()
214        .init_resource::<turns::Told>()
215        .register_required_components::<rig_ecs::agent::Partial, transcript::StreamingCalls>()
216        .add_observer(transcript::forget_streams)
217        .add_observer(clipboard::type_pasted)
218        .add_observer(render::redraw_on_remove)
219        .add_observer(render::forget_looks)
220        .add_systems(Startup, (view::load_notices, view::load_draft))
221        .add_systems(
222            PreUpdate,
223            (
224                (
225                    input::receive_events.run_if(resource_exists::<input::TerminalInput>),
226                    complete::receive_paths,
227                )
228                    .chain()
229                    .before(input::run_keys),
230                (pick::mark_highlighted, input::note_input_context)
231                    .chain()
232                    .after(input::run_keys),
233            ),
234        )
235        .add_systems(
236            TuiKeys,
237            (
238                input::note_input_context.before(InputSystems::Filter),
239                input::read_input.in_set(InputSystems::Dispatch),
240            ),
241        )
242        .add_systems(
243            Update,
244            (
245                // A command typed this frame runs in the agent loop, so
246                // its picker, its notices and a refusal come back in this
247                // frame, and so does a question asked there.
248                (
249                    pick::queue_choices,
250                    pick::mark_highlighted,
251                    pick::follow_previews,
252                    keys::arm_inline_keys,
253                    view::collect_notices,
254                )
255                    .chain()
256                    .after(AgentLoop),
257                view::recall_messages.after(AgentLoop),
258                history::record.in_set(HistorySystems::Record),
259                history::save.in_set(HistorySystems::Save),
260            ),
261        )
262        .add_systems(
263            render::LayOut,
264            (
265                render::layout.run_if(terminal::has_screen.and_then(render::needs_redraw)),
266                pane::lay_out_panes.run_if(render::frame_due),
267            )
268                .chain(),
269        )
270        .add_systems(
271            PostUpdate,
272            (
273                terminal::run_in_terminal
274                    .run_if(any_match_filter::<(With<RunInTerminal>, Without<TerminalExit>)>)
275                    .before(TuiSystems::Layout),
276                transcript::note_effects.in_set(TuiSystems::Prepare),
277                turns::tell_state,
278                input::note_input_context.in_set(TuiSystems::Prepare),
279                (
280                    pane::draw_panes,
281                    editor::draw_editors,
282                    status::draw_status_lines,
283                    pick::draw_inline_strips,
284                )
285                    .in_set(TuiSystems::Draw),
286                (
287                    complete::follow_input,
288                    transcript::follow_streams,
289                    render::lay_out_frame,
290                )
291                    .chain()
292                    .in_set(TuiSystems::Layout),
293                render::lay_out_fitted
294                    .after(TuiSystems::Fit)
295                    .before(TuiSystems::Draw),
296                (
297                    terminal::report_releases.run_if(resource_exists::<terminal::Tui>),
298                    render::render,
299                )
300                    .chain()
301                    .in_set(TuiSystems::Render)
302                    .run_if(terminal::has_screen),
303            ),
304        )
305        .add_systems(
306            Last,
307            (
308                view::keep_draft,
309                // With the notices sent in `Last`, such as a failed last save.
310                (view::collect_notices, view::keep_notices)
311                    .chain()
312                    .after(rig_ecs::WriteLogs),
313            )
314                .in_set(bevy_app::OnAppExitSystems)
315                .run_if(on_message::<AppExit>),
316        )
317        // After the frame, and after what runs on the way out, which may
318        // write to the terminal before the view lets it go.
319        .add_systems(
320            Last,
321            turns::restore_title
322                .run_if(on_message::<AppExit>)
323                .in_set(bevy_app::OnAppExitSystems),
324        )
325        .add_systems(
326            Last,
327            (
328                terminal::write_to_terminal.run_if(terminal::has_screen),
329                clipboard::copy_to_system.run_if(resource_exists::<terminal::Tui>),
330            )
331                .after(bevy_app::OnAppExitSystems),
332        )
333        .add_observer(keys::release_focus)
334        .add_observer(view::on_focus)
335        .add_observer(view::on_scroll_to)
336        .add_observer(transcript::note_agent_name)
337        .add_observer(turns::turn_started)
338        .add_observer(turns::turn_ended);
339}
340
341/// The look of a failed build's note, which the model reads too.
342fn build_failed(entry: &EntryView<'_>) -> Vec<ratatui::text::Line<'static>> {
343    use ratatui::style::Style;
344    let mut lines = vec![ratatui::text::Line::styled(
345        "✗ build failed (noted for the agent)",
346        Style::new().red().bold(),
347    )];
348    lines.extend(text_rows(entry.text, Style::new().red().dim()));
349    lines
350}
351
352#[cfg(test)]
353mod tests;