vtcode_tui/core_tui/app/session/
mod.rs1use std::collections::VecDeque;
2
3pub(super) use ratatui::crossterm::event::{
4 Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, MouseEvent, MouseEventKind,
5};
6pub(super) use ratatui::prelude::*;
7pub(super) use ratatui::widgets::Clear;
8pub(super) use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
9
10use crate::core_tui::app::types::{
11 DiffOverlayRequest, DiffPreviewState, InlineCommand, InlineEvent, SlashCommandItem,
12};
13use crate::core_tui::runner::TuiSessionDriver;
14use crate::core_tui::session::Session as CoreSessionState;
15
16pub mod diff_preview;
17mod events;
18pub mod file_palette;
19pub mod history_picker;
20mod impl_events;
21mod impl_render;
22mod layout;
23mod palette;
24pub mod render;
25pub mod slash;
26pub mod slash_palette;
27pub mod trust;
28
29use self::file_palette::FilePalette;
30use self::history_picker::HistoryPickerState;
31use self::slash_palette::SlashPalette;
32
33pub struct AppSession {
35 pub(crate) core: CoreSessionState,
36 pub(crate) file_palette: Option<FilePalette>,
37 pub(crate) file_palette_active: bool,
38 pub(crate) inline_lists_visible: bool,
39 pub(crate) slash_palette: SlashPalette,
40 pub(crate) history_picker_state: HistoryPickerState,
41 pub(crate) show_task_panel: bool,
42 pub(crate) task_panel_lines: Vec<String>,
43 pub(crate) diff_preview_state: Option<DiffPreviewState>,
44 pub(crate) diff_overlay_queue: VecDeque<DiffOverlayRequest>,
45}
46
47pub(super) type Session = AppSession;
48
49impl AppSession {
50 pub fn new_with_logs(
51 theme: crate::core_tui::types::InlineTheme,
52 placeholder: Option<String>,
53 view_rows: u16,
54 show_logs: bool,
55 appearance: Option<crate::core_tui::session::config::AppearanceConfig>,
56 slash_commands: Vec<SlashCommandItem>,
57 app_name: String,
58 ) -> Self {
59 let core = CoreSessionState::new_with_logs(
60 theme,
61 placeholder,
62 view_rows,
63 show_logs,
64 appearance,
65 app_name,
66 );
67
68 Self {
69 core,
70 file_palette: None,
71 file_palette_active: false,
72 inline_lists_visible: true,
73 slash_palette: SlashPalette::with_commands(slash_commands),
74 history_picker_state: HistoryPickerState::new(),
75 show_task_panel: false,
76 task_panel_lines: Vec::new(),
77 diff_preview_state: None,
78 diff_overlay_queue: VecDeque::new(),
79 }
80 }
81
82 pub fn new(
83 theme: crate::core_tui::types::InlineTheme,
84 placeholder: Option<String>,
85 view_rows: u16,
86 ) -> Self {
87 Self::new_with_logs(
88 theme,
89 placeholder,
90 view_rows,
91 true,
92 None,
93 Vec::new(),
94 "Agent TUI".to_string(),
95 )
96 }
97
98 pub fn core(&self) -> &CoreSessionState {
99 &self.core
100 }
101
102 pub fn core_mut(&mut self) -> &mut CoreSessionState {
103 &mut self.core
104 }
105
106 pub(crate) fn inline_lists_visible(&self) -> bool {
107 self.inline_lists_visible
108 }
109
110 pub(crate) fn toggle_inline_lists_visibility(&mut self) {
111 self.inline_lists_visible = !self.inline_lists_visible;
112 self.core.mark_dirty();
113 }
114
115 pub(crate) fn ensure_inline_lists_visible_for_trigger(&mut self) {
116 if !self.inline_lists_visible {
117 self.inline_lists_visible = true;
118 self.core.mark_dirty();
119 }
120 }
121
122 pub(crate) fn update_input_triggers(&mut self) {
123 if !self.core.input_enabled() {
124 if self.file_palette_active {
125 self.close_file_palette();
126 }
127 slash::clear_slash_suggestions(self);
128 return;
129 }
130
131 self.check_file_reference_trigger();
132 slash::update_slash_suggestions(self);
133 }
134
135 pub(crate) fn set_task_panel_visible(&mut self, visible: bool) {
136 if self.show_task_panel != visible {
137 self.show_task_panel = visible;
138 self.core.mark_dirty();
139 }
140 }
141
142 pub(crate) fn set_task_panel_lines(&mut self, lines: Vec<String>) {
143 self.task_panel_lines = lines;
144 self.core.mark_dirty();
145 }
146
147 pub(crate) fn diff_preview_state(&self) -> Option<&DiffPreviewState> {
148 self.diff_preview_state.as_ref()
149 }
150
151 pub(crate) fn diff_preview_state_mut(&mut self) -> Option<&mut DiffPreviewState> {
152 self.diff_preview_state.as_mut()
153 }
154
155 pub(crate) fn show_diff_overlay(&mut self, request: DiffOverlayRequest) {
156 if self.diff_preview_state.is_some() {
157 self.diff_overlay_queue.push_back(request);
158 return;
159 }
160
161 let mut state = DiffPreviewState::new_with_mode(
162 request.file_path,
163 request.before,
164 request.after,
165 request.hunks,
166 request.mode,
167 );
168 state.current_hunk = request.current_hunk;
169 self.diff_preview_state = Some(state);
170 self.core.set_input_enabled(false);
171 self.core.set_cursor_visible(false);
172 self.core.mark_dirty();
173 }
174
175 pub(crate) fn close_diff_overlay(&mut self) {
176 if self.diff_preview_state.is_none() {
177 return;
178 }
179 self.diff_preview_state = None;
180 self.core.set_input_enabled(true);
181 self.core.set_cursor_visible(true);
182 if let Some(next) = self.diff_overlay_queue.pop_front() {
183 self.show_diff_overlay(next);
184 return;
185 }
186 self.core.mark_dirty();
187 }
188
189 pub fn handle_command(&mut self, command: InlineCommand) {
190 match command {
191 InlineCommand::SetTaskPanelVisible(visible) => {
192 self.set_task_panel_visible(visible);
193 }
194 InlineCommand::SetTaskPanelLines(lines) => {
195 self.set_task_panel_lines(lines);
196 }
197 InlineCommand::LoadFilePalette { files, workspace } => {
198 self.load_file_palette(files, workspace);
199 }
200 InlineCommand::SetInput(value) => {
201 self.core
202 .handle_command(crate::core_tui::types::InlineCommand::SetInput(value));
203 self.update_input_triggers();
204 }
205 InlineCommand::ApplySuggestedPrompt(value) => {
206 self.core.handle_command(
207 crate::core_tui::types::InlineCommand::ApplySuggestedPrompt(value),
208 );
209 self.update_input_triggers();
210 }
211 InlineCommand::ClearInput => {
212 self.core
213 .handle_command(crate::core_tui::types::InlineCommand::ClearInput);
214 self.update_input_triggers();
215 }
216 InlineCommand::OpenHistoryPicker => {
217 events::open_history_picker(self);
218 }
219 InlineCommand::CloseOverlay => {
220 if self.diff_preview_state.is_some() {
221 self.close_diff_overlay();
222 } else {
223 self.core
224 .handle_command(crate::core_tui::types::InlineCommand::CloseOverlay);
225 }
226 }
227 InlineCommand::ShowOverlay { request } => match *request {
228 crate::core_tui::app::types::OverlayRequest::Diff(request) => {
229 self.show_diff_overlay(request);
230 }
231 crate::core_tui::app::types::OverlayRequest::Modal(request) => {
232 self.core
233 .show_overlay(crate::core_tui::types::OverlayRequest::Modal(
234 request.into(),
235 ));
236 }
237 crate::core_tui::app::types::OverlayRequest::List(request) => {
238 self.core
239 .show_overlay(crate::core_tui::types::OverlayRequest::List(request.into()));
240 }
241 crate::core_tui::app::types::OverlayRequest::Wizard(request) => {
242 self.core
243 .show_overlay(crate::core_tui::types::OverlayRequest::Wizard(
244 request.into(),
245 ));
246 }
247 },
248 _ => {
249 if let Some(core_cmd) = to_core_command(&command) {
250 self.core.handle_command(core_cmd);
251 }
252 }
253 }
254 }
255}
256
257impl std::ops::Deref for AppSession {
258 type Target = CoreSessionState;
259
260 fn deref(&self) -> &Self::Target {
261 &self.core
262 }
263}
264
265impl std::ops::DerefMut for AppSession {
266 fn deref_mut(&mut self) -> &mut Self::Target {
267 &mut self.core
268 }
269}
270
271fn to_core_command(command: &InlineCommand) -> Option<crate::core_tui::types::InlineCommand> {
272 use crate::core_tui::types::InlineCommand as CoreCommand;
273
274 Some(match command {
275 InlineCommand::AppendLine { kind, segments } => CoreCommand::AppendLine {
276 kind: *kind,
277 segments: segments.clone(),
278 },
279 InlineCommand::AppendPastedMessage {
280 kind,
281 text,
282 line_count,
283 } => CoreCommand::AppendPastedMessage {
284 kind: *kind,
285 text: text.clone(),
286 line_count: *line_count,
287 },
288 InlineCommand::Inline { kind, segment } => CoreCommand::Inline {
289 kind: *kind,
290 segment: segment.clone(),
291 },
292 InlineCommand::ReplaceLast {
293 count,
294 kind,
295 lines,
296 link_ranges,
297 } => CoreCommand::ReplaceLast {
298 count: *count,
299 kind: *kind,
300 lines: lines.clone(),
301 link_ranges: link_ranges.clone(),
302 },
303 InlineCommand::SetPrompt { prefix, style } => CoreCommand::SetPrompt {
304 prefix: prefix.clone(),
305 style: style.clone(),
306 },
307 InlineCommand::SetPlaceholder { hint, style } => CoreCommand::SetPlaceholder {
308 hint: hint.clone(),
309 style: style.clone(),
310 },
311 InlineCommand::SetMessageLabels { agent, user } => CoreCommand::SetMessageLabels {
312 agent: agent.clone(),
313 user: user.clone(),
314 },
315 InlineCommand::SetHeaderContext { context } => CoreCommand::SetHeaderContext {
316 context: context.clone(),
317 },
318 InlineCommand::SetInputStatus { left, right } => CoreCommand::SetInputStatus {
319 left: left.clone(),
320 right: right.clone(),
321 },
322 InlineCommand::SetTheme { theme } => CoreCommand::SetTheme {
323 theme: theme.clone(),
324 },
325 InlineCommand::SetAppearance { appearance } => CoreCommand::SetAppearance {
326 appearance: appearance.clone(),
327 },
328 InlineCommand::SetVimModeEnabled(enabled) => CoreCommand::SetVimModeEnabled(*enabled),
329 InlineCommand::SetQueuedInputs { entries } => CoreCommand::SetQueuedInputs {
330 entries: entries.clone(),
331 },
332 InlineCommand::SetCursorVisible(value) => CoreCommand::SetCursorVisible(*value),
333 InlineCommand::SetInputEnabled(value) => CoreCommand::SetInputEnabled(*value),
334 InlineCommand::SetInput(value) => CoreCommand::SetInput(value.clone()),
335 InlineCommand::ApplySuggestedPrompt(value) => {
336 CoreCommand::ApplySuggestedPrompt(value.clone())
337 }
338 InlineCommand::ClearInput => CoreCommand::ClearInput,
339 InlineCommand::ForceRedraw => CoreCommand::ForceRedraw,
340 InlineCommand::CloseOverlay => CoreCommand::CloseOverlay,
341 InlineCommand::ClearScreen => CoreCommand::ClearScreen,
342 InlineCommand::SuspendEventLoop => CoreCommand::SuspendEventLoop,
343 InlineCommand::ResumeEventLoop => CoreCommand::ResumeEventLoop,
344 InlineCommand::ClearInputQueue => CoreCommand::ClearInputQueue,
345 InlineCommand::SetEditingMode(mode) => CoreCommand::SetEditingMode(*mode),
346 InlineCommand::SetAutonomousMode(enabled) => CoreCommand::SetAutonomousMode(*enabled),
347 InlineCommand::SetSkipConfirmations(skip) => CoreCommand::SetSkipConfirmations(*skip),
348 InlineCommand::Shutdown => CoreCommand::Shutdown,
349 InlineCommand::SetReasoningStage(stage) => CoreCommand::SetReasoningStage(stage.clone()),
350 InlineCommand::SetTaskPanelVisible(_)
351 | InlineCommand::SetTaskPanelLines(_)
352 | InlineCommand::LoadFilePalette { .. }
353 | InlineCommand::OpenHistoryPicker
354 | InlineCommand::ShowOverlay { .. } => return None,
355 })
356}
357
358impl TuiSessionDriver for AppSession {
359 type Command = InlineCommand;
360 type Event = InlineEvent;
361
362 fn handle_command(&mut self, command: Self::Command) {
363 AppSession::handle_command(self, command);
364 }
365
366 fn handle_event(
367 &mut self,
368 event: CrosstermEvent,
369 events: &UnboundedSender<Self::Event>,
370 callback: Option<&(dyn Fn(&Self::Event) + Send + Sync + 'static)>,
371 ) {
372 AppSession::handle_event(self, event, events, callback);
373 }
374
375 fn handle_tick(&mut self) {
376 self.core.handle_tick();
377 }
378
379 fn render(&mut self, frame: &mut Frame<'_>) {
380 AppSession::render(self, frame);
381 }
382
383 fn take_redraw(&mut self) -> bool {
384 self.core.take_redraw()
385 }
386
387 fn use_steady_cursor(&self) -> bool {
388 self.core.use_steady_cursor()
389 }
390
391 fn should_exit(&self) -> bool {
392 self.core.should_exit()
393 }
394
395 fn request_exit(&mut self) {
396 self.core.request_exit();
397 }
398
399 fn mark_dirty(&mut self) {
400 self.core.mark_dirty();
401 }
402
403 fn update_terminal_title(&mut self) {
404 self.core.update_terminal_title();
405 }
406
407 fn clear_terminal_title(&mut self) {
408 self.core.clear_terminal_title();
409 }
410
411 fn is_running_activity(&self) -> bool {
412 self.core.is_running_activity()
413 }
414
415 fn has_status_spinner(&self) -> bool {
416 self.core.has_status_spinner()
417 }
418
419 fn thinking_spinner_active(&self) -> bool {
420 self.core.thinking_spinner.is_active
421 }
422
423 fn has_active_navigation_ui(&self) -> bool {
424 self.core.has_active_overlay()
425 || self.diff_preview_state.is_some()
426 || self.file_palette_active
427 || self.history_picker_state.active
428 || slash::slash_navigation_available(self)
429 }
430
431 fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
432 self.core.apply_coalesced_scroll(line_delta, page_delta);
433 }
434
435 fn set_show_logs(&mut self, show: bool) {
436 self.core.show_logs = show;
437 }
438
439 fn set_active_pty_sessions(
440 &mut self,
441 sessions: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
442 ) {
443 self.core.active_pty_sessions = sessions;
444 }
445
446 fn set_workspace_root(&mut self, root: Option<std::path::PathBuf>) {
447 self.core.set_workspace_root(root);
448 }
449
450 fn set_log_receiver(&mut self, receiver: UnboundedReceiver<crate::core_tui::log::LogEntry>) {
451 self.core.set_log_receiver(receiver);
452 }
453}