user: InteractScreen extracted, all screens use ScreenView trait

InteractScreen in chat.rs owns conversation/autonomous/tools panes,
textarea, input history, scroll state. App is now just shared state
(status, sampling params, agent_state, channel_status, idle_info).

Event loop holds InteractScreen separately for UiMessage routing.
Overlay screens (F2-F5) in screens vec. F-key switching preserves
state across screen changes.

handle_ui_message moved from App to InteractScreen.
handle_key split: global keys on App, screen keys in tick().
draw dispatch eliminated — each screen draws itself.

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
Kent Overstreet 2026-04-05 18:57:54 -04:00
parent 8418bc9bc9
commit 68f115b880
8 changed files with 276 additions and 259 deletions

View file

@ -15,20 +15,18 @@ pub mod thalamus;
// --- TUI infrastructure (moved from tui/mod.rs) ---
use ratatui::crossterm::{
event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use ratatui::{
backend::CrosstermBackend,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
Frame, Terminal,
};
use std::io;
use crate::user::ui_channel::{ContextInfo, SharedContextState, StatusInfo, UiMessage};
use crate::user::ui_channel::{ContextInfo, SharedContextState, StatusInfo};
/// Build the screen legend from the screen table.
pub(crate) fn screen_legend() -> String {
@ -246,7 +244,7 @@ pub enum ScreenAction {
/// A screen that can draw itself and handle input.
pub(crate) trait ScreenView: Send {
fn tick(&mut self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect,
key: Option<ratatui::crossterm::event::KeyEvent>, app: &App) -> Option<ScreenAction>;
key: Option<ratatui::crossterm::event::KeyEvent>, app: &mut App) -> Option<ScreenAction>;
fn label(&self) -> &'static str;
}
@ -276,29 +274,17 @@ pub(crate) struct ChannelStatus {
}
pub struct App {
pub(crate) autonomous: PaneState,
pub(crate) conversation: PaneState,
pub(crate) tools: PaneState,
pub(crate) status: StatusInfo,
pub(crate) activity: String,
pub(crate) turn_started: Option<std::time::Instant>,
pub(crate) call_started: Option<std::time::Instant>,
pub(crate) call_timeout_secs: u64,
pub(crate) needs_assistant_marker: bool,
pub running_processes: u32,
pub reasoning_effort: String,
pub temperature: f32,
pub top_p: f32,
pub top_k: u32,
pub(crate) active_tools: crate::user::ui_channel::SharedActiveTools,
pub(crate) active_pane: ActivePane,
pub textarea: tui_textarea::TextArea<'static>,
input_history: Vec<String>,
history_index: Option<usize>,
pub should_quit: bool,
pub submitted: Vec<String>,
pub hotkey_actions: Vec<HotkeyAction>,
pub(crate) pane_areas: [Rect; 3],
pub(crate) context_info: Option<ContextInfo>,
pub(crate) shared_context: SharedContextState,
pub(crate) agent_state: Vec<crate::subconscious::subconscious::AgentSnapshot>,
@ -309,218 +295,39 @@ pub struct App {
impl App {
pub fn new(model: String, shared_context: SharedContextState, active_tools: crate::user::ui_channel::SharedActiveTools) -> Self {
Self {
autonomous: PaneState::new(true),
conversation: PaneState::new(true),
tools: PaneState::new(false),
status: StatusInfo {
dmn_state: "resting".into(), dmn_turns: 0, dmn_max_turns: 20,
prompt_tokens: 0, completion_tokens: 0, model,
turn_tools: 0, context_budget: String::new(),
},
activity: String::new(),
turn_started: None, call_started: None, call_timeout_secs: 60,
needs_assistant_marker: false, running_processes: 0,
running_processes: 0,
reasoning_effort: "none".to_string(),
temperature: 0.6,
top_p: 0.95,
top_k: 20,
active_tools, active_pane: ActivePane::Conversation,
textarea: new_textarea(vec![String::new()]),
input_history: Vec::new(), history_index: None,
active_tools,
should_quit: false, submitted: Vec::new(), hotkey_actions: Vec::new(),
pane_areas: [Rect::default(); 3],
context_info: None, shared_context,
agent_state: Vec::new(),
channel_status: Vec::new(), idle_info: None,
}
}
pub fn drain_messages(&mut self, rx: &mut crate::user::ui_channel::UiReceiver) -> bool {
let mut any = false;
while let Ok(msg) = rx.try_recv() {
self.handle_ui_message(msg);
any = true;
}
any
}
pub fn handle_global_key_old(&mut self, _key: KeyEvent) -> bool { false } // placeholder
pub fn handle_ui_message(&mut self, msg: UiMessage) {
use crate::user::ui_channel::StreamTarget;
match msg {
UiMessage::TextDelta(text, target) => match target {
StreamTarget::Conversation => {
if self.needs_assistant_marker {
self.conversation.pending_marker = Marker::Assistant;
self.needs_assistant_marker = false;
}
self.conversation.current_color = Color::Reset;
self.conversation.append_text(&text);
}
StreamTarget::Autonomous => {
self.autonomous.current_color = Color::Reset;
self.autonomous.append_text(&text);
}
},
UiMessage::UserInput(text) => {
self.conversation.push_line_with_marker(text, Color::Cyan, Marker::User);
self.turn_started = Some(std::time::Instant::now());
self.needs_assistant_marker = true;
self.status.turn_tools = 0;
}
UiMessage::ToolCall { name, args_summary } => {
self.status.turn_tools += 1;
let line = if args_summary.is_empty() { format!("[{}]", name) }
else { format!("[{}] {}", name, args_summary) };
self.tools.push_line(line, Color::Yellow);
}
UiMessage::ToolResult { name: _, result } => {
for line in result.lines() {
self.tools.push_line(format!(" {}", line), Color::DarkGray);
}
self.tools.push_line(String::new(), Color::Reset);
}
UiMessage::DmnAnnotation(text) => {
self.autonomous.push_line(text, Color::Yellow);
self.turn_started = Some(std::time::Instant::now());
self.needs_assistant_marker = true;
self.status.turn_tools = 0;
}
UiMessage::StatusUpdate(info) => {
if !info.dmn_state.is_empty() {
self.status.dmn_state = info.dmn_state;
self.status.dmn_turns = info.dmn_turns;
self.status.dmn_max_turns = info.dmn_max_turns;
}
if info.prompt_tokens > 0 { self.status.prompt_tokens = info.prompt_tokens; }
if !info.model.is_empty() { self.status.model = info.model; }
if !info.context_budget.is_empty() { self.status.context_budget = info.context_budget; }
}
UiMessage::Activity(text) => {
if text.is_empty() {
self.call_started = None;
} else if self.activity.is_empty() || self.call_started.is_none() {
self.call_started = Some(std::time::Instant::now());
self.call_timeout_secs = crate::config::get().api_stream_timeout_secs;
}
self.activity = text;
}
UiMessage::Reasoning(text) => {
self.autonomous.current_color = Color::DarkGray;
self.autonomous.append_text(&text);
}
UiMessage::ToolStarted { .. } | UiMessage::ToolFinished { .. } => {}
UiMessage::Debug(text) => {
self.tools.push_line(format!("[debug] {}", text), Color::DarkGray);
}
UiMessage::Info(text) => {
self.conversation.push_line(text, Color::Cyan);
}
UiMessage::ContextInfoUpdate(info) => { self.context_info = Some(info); }
UiMessage::AgentUpdate(agents) => { self.agent_state = agents; }
}
}
/// Handle global keys only (Ctrl combos, F-keys). Screen-specific
/// keys are handled by the active ScreenView's tick method.
pub fn handle_key(&mut self, key: KeyEvent) {
/// Handle global keys only (Ctrl combos).
pub fn handle_global_key(&mut self, key: KeyEvent) -> bool {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => { self.should_quit = true; return; }
KeyCode::Char('r') => { self.hotkey_actions.push(HotkeyAction::CycleReasoning); return; }
KeyCode::Char('k') => { self.hotkey_actions.push(HotkeyAction::KillProcess); return; }
KeyCode::Char('p') => { self.hotkey_actions.push(HotkeyAction::CycleAutonomy); return; }
KeyCode::Char('c') => { self.should_quit = true; return true; }
KeyCode::Char('r') => { self.hotkey_actions.push(HotkeyAction::CycleReasoning); return true; }
KeyCode::Char('k') => { self.hotkey_actions.push(HotkeyAction::KillProcess); return true; }
KeyCode::Char('p') => { self.hotkey_actions.push(HotkeyAction::CycleAutonomy); return true; }
_ => {}
}
}
match key.code {
KeyCode::Esc => { self.hotkey_actions.push(HotkeyAction::Interrupt); }
KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::SHIFT) => {
let input: String = self.textarea.lines().join("\n");
if !input.is_empty() {
if self.input_history.last().map_or(true, |h| h != &input) { self.input_history.push(input.clone()); }
self.history_index = None;
self.submitted.push(input);
self.textarea = new_textarea(vec![String::new()]);
}
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => self.scroll_active_up(3),
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => self.scroll_active_down(3),
KeyCode::Up => {
if !self.input_history.is_empty() {
let idx = match self.history_index { None => self.input_history.len() - 1, Some(i) => i.saturating_sub(1) };
self.history_index = Some(idx);
let mut ta = new_textarea(self.input_history[idx].lines().map(String::from).collect());
ta.move_cursor(tui_textarea::CursorMove::End);
self.textarea = ta;
}
}
KeyCode::Down => {
if let Some(idx) = self.history_index {
if idx + 1 < self.input_history.len() {
self.history_index = Some(idx + 1);
let mut ta = new_textarea(self.input_history[idx + 1].lines().map(String::from).collect());
ta.move_cursor(tui_textarea::CursorMove::End);
self.textarea = ta;
} else {
self.history_index = None;
self.textarea = new_textarea(vec![String::new()]);
}
}
}
KeyCode::PageUp => self.scroll_active_up(10),
KeyCode::PageDown => self.scroll_active_down(10),
KeyCode::Tab => {
self.active_pane = match self.active_pane {
ActivePane::Autonomous => ActivePane::Tools,
ActivePane::Tools => ActivePane::Conversation,
ActivePane::Conversation => ActivePane::Autonomous,
};
}
_ => { self.textarea.input(key); }
}
}
fn scroll_active_up(&mut self, n: u16) {
match self.active_pane {
ActivePane::Autonomous => self.autonomous.scroll_up(n),
ActivePane::Conversation => self.conversation.scroll_up(n),
ActivePane::Tools => self.tools.scroll_up(n),
}
}
fn scroll_active_down(&mut self, n: u16) {
match self.active_pane {
ActivePane::Autonomous => self.autonomous.scroll_down(n),
ActivePane::Conversation => self.conversation.scroll_down(n),
ActivePane::Tools => self.tools.scroll_down(n),
}
}
pub fn handle_resize(&mut self, _width: u16, _height: u16) {}
pub fn handle_mouse(&mut self, mouse: MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_active_up(3),
MouseEventKind::ScrollDown => self.scroll_active_down(3),
MouseEventKind::Down(MouseButton::Left) => {
let (x, y) = (mouse.column, mouse.row);
for (i, area) in self.pane_areas.iter().enumerate() {
if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height {
self.active_pane = match i { 0 => ActivePane::Autonomous, 1 => ActivePane::Conversation, _ => ActivePane::Tools };
break;
}
}
}
_ => {}
}
}
/// Draw the interact (F1) screen. Overlay screens are drawn
/// by the event loop via ScreenView::tick.
pub fn draw(&mut self, frame: &mut Frame) {
let size = frame.area();
self.draw_main(frame, size);
false
}
pub fn set_channel_status(&mut self, channels: Vec<(String, bool, u32)>) {
@ -539,16 +346,16 @@ impl App {
}
pub fn init_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
pub fn init_terminal() -> io::Result<ratatui::Terminal<CrosstermBackend<io::Stdout>>> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
stdout.execute(EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
Terminal::new(backend)
ratatui::Terminal::new(backend)
}
pub fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
pub fn restore_terminal(terminal: &mut ratatui::Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
terminal::disable_raw_mode()?;
terminal.backend_mut().execute(DisableMouseCapture)?;
terminal.backend_mut().execute(LeaveAlternateScreen)?;