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:
parent
8418bc9bc9
commit
68f115b880
8 changed files with 276 additions and 259 deletions
250
src/user/chat.rs
250
src/user/chat.rs
|
|
@ -9,15 +9,225 @@ use ratatui::{
|
|||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame,
|
||||
crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
|
||||
};
|
||||
|
||||
use super::{ActivePane, App, Marker, PaneState, screen_legend};
|
||||
use super::{
|
||||
ActivePane, App, HotkeyAction, Marker, PaneState, ScreenAction, ScreenView,
|
||||
new_textarea, screen_legend,
|
||||
};
|
||||
use crate::user::ui_channel::{UiMessage, StreamTarget};
|
||||
|
||||
impl App {
|
||||
pub(crate) struct InteractScreen {
|
||||
pub(crate) autonomous: PaneState,
|
||||
pub(crate) conversation: PaneState,
|
||||
pub(crate) tools: PaneState,
|
||||
pub(crate) textarea: tui_textarea::TextArea<'static>,
|
||||
pub(crate) input_history: Vec<String>,
|
||||
pub(crate) history_index: Option<usize>,
|
||||
pub(crate) active_pane: ActivePane,
|
||||
pub(crate) pane_areas: [Rect; 3],
|
||||
pub(crate) needs_assistant_marker: bool,
|
||||
pub(crate) turn_started: Option<std::time::Instant>,
|
||||
pub(crate) call_started: Option<std::time::Instant>,
|
||||
pub(crate) call_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl InteractScreen {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
autonomous: PaneState::new(true),
|
||||
conversation: PaneState::new(true),
|
||||
tools: PaneState::new(false),
|
||||
textarea: new_textarea(vec![String::new()]),
|
||||
input_history: Vec::new(),
|
||||
history_index: None,
|
||||
active_pane: ActivePane::Conversation,
|
||||
pane_areas: [Rect::default(); 3],
|
||||
needs_assistant_marker: false,
|
||||
turn_started: None,
|
||||
call_started: None,
|
||||
call_timeout_secs: 60,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a UiMessage — update pane state.
|
||||
pub fn handle_ui_message(&mut self, msg: &UiMessage, app: &mut App) {
|
||||
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.clone(), Color::Cyan, Marker::User);
|
||||
self.turn_started = Some(std::time::Instant::now());
|
||||
self.needs_assistant_marker = true;
|
||||
app.status.turn_tools = 0;
|
||||
}
|
||||
UiMessage::ToolCall { name, args_summary } => {
|
||||
app.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 { 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.clone(), Color::Yellow);
|
||||
self.turn_started = Some(std::time::Instant::now());
|
||||
self.needs_assistant_marker = true;
|
||||
app.status.turn_tools = 0;
|
||||
}
|
||||
UiMessage::StatusUpdate(info) => {
|
||||
if !info.dmn_state.is_empty() {
|
||||
app.status.dmn_state = info.dmn_state.clone();
|
||||
app.status.dmn_turns = info.dmn_turns;
|
||||
app.status.dmn_max_turns = info.dmn_max_turns;
|
||||
}
|
||||
if info.prompt_tokens > 0 { app.status.prompt_tokens = info.prompt_tokens; }
|
||||
if !info.model.is_empty() { app.status.model = info.model.clone(); }
|
||||
if !info.context_budget.is_empty() { app.status.context_budget = info.context_budget.clone(); }
|
||||
}
|
||||
UiMessage::Activity(text) => {
|
||||
if text.is_empty() {
|
||||
self.call_started = None;
|
||||
} else if app.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;
|
||||
}
|
||||
app.activity = text.clone();
|
||||
}
|
||||
UiMessage::Reasoning(text) => {
|
||||
self.autonomous.current_color = Color::DarkGray;
|
||||
self.autonomous.append_text(text);
|
||||
}
|
||||
UiMessage::Debug(text) => {
|
||||
self.tools.push_line(format!("[debug] {}", text), Color::DarkGray);
|
||||
}
|
||||
UiMessage::Info(text) => {
|
||||
self.conversation.push_line(text.clone(), Color::Cyan);
|
||||
}
|
||||
UiMessage::ContextInfoUpdate(info) => { app.context_info = Some(info.clone()); }
|
||||
UiMessage::AgentUpdate(agents) => { app.agent_state = agents.clone(); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenView for InteractScreen {
|
||||
fn label(&self) -> &'static str { "interact" }
|
||||
|
||||
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
||||
key: Option<KeyEvent>, app: &mut App) -> Option<ScreenAction> {
|
||||
// Handle keys
|
||||
if let Some(key) = key {
|
||||
match key.code {
|
||||
KeyCode::Esc => return Some(ScreenAction::Hotkey(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;
|
||||
// TODO: push to submitted via app or return action
|
||||
}
|
||||
}
|
||||
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); }
|
||||
}
|
||||
}
|
||||
|
||||
// Draw
|
||||
self.draw_main(frame, area, app);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractScreen {
|
||||
/// Draw the main (F1) screen — four-pane layout with status bar.
|
||||
pub(crate) fn draw_main(&mut self, frame: &mut Frame, size: Rect) {
|
||||
pub(crate) fn draw_main(&mut self, frame: &mut Frame, size: Rect, app: &App) {
|
||||
// Main layout: content area + active tools overlay + status bar
|
||||
let active_tools = self.active_tools.lock().unwrap();
|
||||
let active_tools = app.active_tools.lock().unwrap();
|
||||
let tool_lines = active_tools.len() as u16;
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
|
|
@ -124,47 +334,47 @@ impl App {
|
|||
}
|
||||
|
||||
// Draw status bar with live activity indicator
|
||||
let timer = if !self.activity.is_empty() {
|
||||
let timer = if !app.activity.is_empty() {
|
||||
let total = self.turn_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
||||
let call = self.call_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
||||
format!(" {}s, {}/{}s", total, call, self.call_timeout_secs)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let tools_info = if self.status.turn_tools > 0 {
|
||||
format!(" ({}t)", self.status.turn_tools)
|
||||
let tools_info = if app.status.turn_tools > 0 {
|
||||
format!(" ({}t)", app.status.turn_tools)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let activity_part = if self.activity.is_empty() {
|
||||
let activity_part = if app.activity.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" | {}{}{}", self.activity, tools_info, timer)
|
||||
format!(" | {}{}{}", app.activity, tools_info, timer)
|
||||
};
|
||||
|
||||
let budget_part = if self.status.context_budget.is_empty() {
|
||||
let budget_part = if app.status.context_budget.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [{}]", self.status.context_budget)
|
||||
format!(" [{}]", app.status.context_budget)
|
||||
};
|
||||
|
||||
let left_status = format!(
|
||||
" {} | {}/{} dmn | {}K tok in{}{}",
|
||||
self.status.dmn_state,
|
||||
self.status.dmn_turns,
|
||||
self.status.dmn_max_turns,
|
||||
self.status.prompt_tokens / 1000,
|
||||
app.status.dmn_state,
|
||||
app.status.dmn_turns,
|
||||
app.status.dmn_max_turns,
|
||||
app.status.prompt_tokens / 1000,
|
||||
budget_part,
|
||||
activity_part,
|
||||
);
|
||||
|
||||
let proc_indicator = if self.running_processes > 0 {
|
||||
format!(" {}proc", self.running_processes)
|
||||
let proc_indicator = if app.running_processes > 0 {
|
||||
format!(" {}proc", app.running_processes)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let reason_indicator = if self.reasoning_effort != "none" {
|
||||
format!(" reason:{}", self.reasoning_effort)
|
||||
let reason_indicator = if app.reasoning_effort != "none" {
|
||||
format!(" reason:{}", app.reasoning_effort)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
|
@ -172,7 +382,7 @@ impl App {
|
|||
"{}{} ^P:pause ^R:reason ^K:kill | {} ",
|
||||
reason_indicator,
|
||||
proc_indicator,
|
||||
self.status.model,
|
||||
app.status.model,
|
||||
);
|
||||
|
||||
// Pad the middle to fill the status bar
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue