forked from kent/consciousness
New ScrollPaneState centralizes height caching, scroll offset, pin-to-bottom, visible range computation, and screen-to-item coordinate mapping. Replaces the hand-rolled scroll bookkeeping that was duplicated across draw_conversation_pane and draw_pane. -170 lines from chat.rs. The scroll_pane module also includes a ScrollPane StatefulWidget ready to wire up for the next step: collapsing the draw functions into render_stateful_widget calls. Co-Authored-By: Kent Overstreet <kent.overstreet@gmail.com>
1187 lines
45 KiB
Rust
1187 lines
45 KiB
Rust
// main_screen.rs — F1 main view rendering
|
|
//
|
|
// The default four-pane layout: autonomous, conversation, tools, status bar.
|
|
// Contains draw_main (the App method), draw_conversation_pane, and draw_pane.
|
|
|
|
use ratatui::{
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Block, Borders, Paragraph, Wrap},
|
|
Frame,
|
|
};
|
|
use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseEvent, MouseEventKind, MouseButton};
|
|
|
|
|
|
use super::{App, ScreenView, screen_legend};
|
|
use crate::agent::context::{AstNode, NodeBody, Role, Ast};
|
|
use crate::mind::MindCommand;
|
|
|
|
// --- Slash command table ---
|
|
|
|
type CmdHandler = fn(&InteractScreen, &str);
|
|
|
|
struct SlashCommand {
|
|
name: &'static str,
|
|
help: &'static str,
|
|
handler: CmdHandler,
|
|
}
|
|
|
|
fn commands() -> Vec<SlashCommand> { vec![
|
|
SlashCommand { name: "/quit", help: "Exit consciousness",
|
|
handler: |_, _| {} },
|
|
SlashCommand { name: "/new", help: "Start fresh session (saves current)",
|
|
handler: |s, _| { let _ = s.mind_tx.send(MindCommand::NewSession); } },
|
|
SlashCommand { name: "/save", help: "Save session to disk",
|
|
handler: |s, _| {
|
|
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("saved"); }
|
|
} },
|
|
SlashCommand { name: "/model", help: "Show/switch model (/model <name>)",
|
|
handler: |s, arg| {
|
|
if arg.is_empty() {
|
|
if let Ok(mut ag) = s.agent.state.try_lock() {
|
|
let names = s.agent.app_config.model_names();
|
|
let label = if names.is_empty() {
|
|
format!("model: {}", s.agent.model())
|
|
} else {
|
|
format!("model: {} ({})", s.agent.model(), names.join(", "))
|
|
};
|
|
ag.notify(label);
|
|
}
|
|
} else {
|
|
let agent = s.agent.clone();
|
|
let name = arg.to_string();
|
|
tokio::spawn(async move {
|
|
let _act = crate::agent::start_activity(&agent, format!("switching to {}...", name)).await;
|
|
cmd_switch_model(&agent, &name).await;
|
|
});
|
|
}
|
|
} },
|
|
SlashCommand { name: "/score", help: "Score memory importance (full matrix)",
|
|
handler: |s, _| { let _ = s.mind_tx.send(MindCommand::ScoreFull); } },
|
|
SlashCommand { name: "/dmn", help: "Show DMN state",
|
|
handler: |s, _| {
|
|
let st = s.shared_mind.lock().unwrap();
|
|
if let Ok(mut ag) = s.agent.state.try_lock() {
|
|
ag.notify(format!("DMN: {:?} ({}/{})", st.dmn, st.dmn_turns, st.max_dmn_turns));
|
|
}
|
|
} },
|
|
SlashCommand { name: "/sleep", help: "Put DMN to sleep",
|
|
handler: |s, _| {
|
|
let mut st = s.shared_mind.lock().unwrap();
|
|
st.dmn = crate::mind::subconscious::State::Resting { since: std::time::Instant::now() };
|
|
st.dmn_turns = 0;
|
|
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN sleeping"); }
|
|
} },
|
|
SlashCommand { name: "/wake", help: "Wake DMN to foraging",
|
|
handler: |s, _| {
|
|
let mut st = s.shared_mind.lock().unwrap();
|
|
if matches!(st.dmn, crate::mind::subconscious::State::Off) { crate::mind::subconscious::set_off(false); }
|
|
st.dmn = crate::mind::subconscious::State::Foraging;
|
|
st.dmn_turns = 0;
|
|
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN foraging"); }
|
|
} },
|
|
SlashCommand { name: "/pause", help: "Full stop — no autonomous ticks (Ctrl+P)",
|
|
handler: |s, _| {
|
|
let mut st = s.shared_mind.lock().unwrap();
|
|
st.dmn = crate::mind::subconscious::State::Paused;
|
|
st.dmn_turns = 0;
|
|
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN paused"); }
|
|
} },
|
|
SlashCommand { name: "/help", help: "Show this help",
|
|
handler: |s, _| { notify_help(&s.agent); } },
|
|
]}
|
|
|
|
fn dispatch_command(input: &str) -> Option<SlashCommand> {
|
|
let cmd_name = input.split_whitespace().next()?;
|
|
commands().into_iter().find(|c| c.name == cmd_name)
|
|
}
|
|
|
|
/// Switch model — used by both /model command and tool-initiated switches.
|
|
pub async fn cmd_switch_model(
|
|
agent: &std::sync::Arc<crate::agent::Agent>,
|
|
name: &str,
|
|
) {
|
|
let resolved = match agent.app_config.resolve_model(name) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
agent.state.lock().await.notify(format!("model error: {}", e));
|
|
return;
|
|
}
|
|
};
|
|
let _new_client = crate::agent::api::ApiClient::new(
|
|
&resolved.api_base, &resolved.api_key, &resolved.model_id,
|
|
);
|
|
let prompt_changed = resolved.prompt_file != agent.prompt_file;
|
|
if prompt_changed {
|
|
agent.compact().await;
|
|
agent.state.lock().await.notify(format!("switched to {} (recompacted)", resolved.model_id));
|
|
} else {
|
|
agent.state.lock().await.notify(format!("switched to {}", resolved.model_id));
|
|
}
|
|
}
|
|
|
|
fn notify_help(agent: &std::sync::Arc<crate::agent::Agent>) {
|
|
if let Ok(mut ag) = agent.state.try_lock() {
|
|
let mut help = String::new();
|
|
for cmd in &commands() {
|
|
help.push_str(&format!("{:12} {}\n", cmd.name, cmd.help));
|
|
}
|
|
help.push_str("Keys: Tab ^Up/Down PgUp/Down Mouse Esc ^P ^R ^K");
|
|
ag.notify(help);
|
|
}
|
|
}
|
|
|
|
/// Turn marker for the conversation pane gutter.
|
|
#[derive(Clone, Copy, PartialEq, Default)]
|
|
enum Marker {
|
|
#[default]
|
|
None,
|
|
User,
|
|
Assistant,
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
enum PaneTarget {
|
|
Conversation,
|
|
ConversationAssistant,
|
|
Tools,
|
|
ToolResult,
|
|
}
|
|
|
|
const MAX_PANE_LINES: usize = 10_000;
|
|
|
|
/// Which pane receives scroll keys.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ActivePane {
|
|
Autonomous,
|
|
Conversation,
|
|
Tools,
|
|
}
|
|
|
|
/// Text selection within a pane. Anchor is where the click started,
|
|
/// cursor is where the mouse currently is. They may be in either order.
|
|
#[derive(Debug, Clone, PartialEq, Default)]
|
|
struct Selection {
|
|
anchor_line: usize,
|
|
anchor_col: usize,
|
|
cursor_line: usize,
|
|
cursor_col: usize,
|
|
}
|
|
|
|
impl Selection {
|
|
fn new(line: usize, col: usize) -> Self {
|
|
Self { anchor_line: line, anchor_col: col, cursor_line: line, cursor_col: col }
|
|
}
|
|
|
|
fn extend(&mut self, line: usize, col: usize) {
|
|
self.cursor_line = line;
|
|
self.cursor_col = col;
|
|
}
|
|
|
|
/// Normalized range: (start_line, start_col, end_line, end_col)
|
|
fn range(&self) -> (usize, usize, usize, usize) {
|
|
if (self.anchor_line, self.anchor_col) <= (self.cursor_line, self.cursor_col) {
|
|
(self.anchor_line, self.anchor_col, self.cursor_line, self.cursor_col)
|
|
} else {
|
|
(self.cursor_line, self.cursor_col, self.anchor_line, self.anchor_col)
|
|
}
|
|
}
|
|
|
|
fn text(&self, lines: &[Line<'static>]) -> String {
|
|
let (start_line, start_col, end_line, end_col) = self.range();
|
|
let mut result = String::new();
|
|
for (i, line) in lines.iter().enumerate() {
|
|
if i < start_line || i > end_line { continue; }
|
|
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
|
let sc = if i == start_line { start_col } else { 0 };
|
|
let ec = if i == end_line { end_col } else { line_text.len() };
|
|
if sc < line_text.len() {
|
|
if let Some(selected) = line_text.get(sc..ec.min(line_text.len())) {
|
|
if !result.is_empty() {
|
|
result.push('\n');
|
|
}
|
|
result.push_str(selected);
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
fn strip_ansi(text: &str) -> String {
|
|
let mut out = String::with_capacity(text.len());
|
|
let mut chars = text.chars().peekable();
|
|
while let Some(ch) = chars.next() {
|
|
if ch == '\x1b' {
|
|
if chars.peek() == Some(&'[') {
|
|
chars.next();
|
|
while let Some(&c) = chars.peek() {
|
|
if c.is_ascii() && (0x20..=0x3F).contains(&(c as u8)) {
|
|
chars.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
if let Some(&c) = chars.peek() {
|
|
if c.is_ascii() && (0x40..=0x7E).contains(&(c as u8)) {
|
|
chars.next();
|
|
}
|
|
}
|
|
} else if let Some(&c) = chars.peek() {
|
|
if c.is_ascii() && (0x40..=0x5F).contains(&(c as u8)) {
|
|
chars.next();
|
|
}
|
|
}
|
|
} else {
|
|
out.push(ch);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn new_textarea(lines: Vec<String>) -> tui_textarea::TextArea<'static> {
|
|
let mut ta = tui_textarea::TextArea::new(lines);
|
|
ta.set_cursor_line_style(Style::default());
|
|
ta.set_wrap_mode(tui_textarea::WrapMode::Word);
|
|
ta
|
|
}
|
|
|
|
fn parse_markdown(md: &str) -> Vec<Line<'static>> {
|
|
tui_markdown::from_str(md)
|
|
.lines
|
|
.into_iter()
|
|
.map(|line| {
|
|
let spans: Vec<Span<'static>> = line.spans.into_iter()
|
|
.map(|span| Span::styled(span.content.into_owned(), span.style))
|
|
.collect();
|
|
let mut result = Line::from(spans).style(line.style);
|
|
result.alignment = line.alignment;
|
|
result
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
struct PaneState {
|
|
lines: Vec<Line<'static>>,
|
|
markers: Vec<Marker>,
|
|
current_line: String,
|
|
current_color: Color,
|
|
md_buffer: String,
|
|
use_markdown: bool,
|
|
pending_marker: Marker,
|
|
scroll: super::scroll_pane::ScrollPaneState,
|
|
selection: Option<Selection>,
|
|
}
|
|
|
|
impl PaneState {
|
|
fn new(use_markdown: bool) -> Self {
|
|
Self {
|
|
lines: Vec::new(), markers: Vec::new(),
|
|
current_line: String::new(), current_color: Color::Reset,
|
|
md_buffer: String::new(), use_markdown,
|
|
pending_marker: Marker::None,
|
|
scroll: super::scroll_pane::ScrollPaneState::new(),
|
|
selection: None,
|
|
}
|
|
}
|
|
|
|
fn evict(&mut self) {
|
|
if self.lines.len() > MAX_PANE_LINES {
|
|
let excess = self.lines.len() - MAX_PANE_LINES;
|
|
self.lines.drain(..excess);
|
|
self.markers.drain(..excess);
|
|
self.scroll.invalidate();
|
|
}
|
|
}
|
|
|
|
fn flush_pending(&mut self) {
|
|
if self.use_markdown && !self.md_buffer.is_empty() {
|
|
let parsed = parse_markdown(&self.md_buffer);
|
|
for (i, line) in parsed.into_iter().enumerate() {
|
|
let marker = if i == 0 { std::mem::take(&mut self.pending_marker) } else { Marker::None };
|
|
self.lines.push(line);
|
|
self.markers.push(marker);
|
|
}
|
|
self.md_buffer.clear();
|
|
}
|
|
if !self.current_line.is_empty() {
|
|
let line = std::mem::take(&mut self.current_line);
|
|
self.lines.push(Line::styled(line, Style::default().fg(self.current_color)));
|
|
self.markers.push(std::mem::take(&mut self.pending_marker));
|
|
}
|
|
}
|
|
|
|
fn push_line(&mut self, line: String, color: Color) {
|
|
self.push_line_with_marker(line, color, Marker::None);
|
|
}
|
|
|
|
fn append_text(&mut self, text: &str) {
|
|
let clean = strip_ansi(text);
|
|
if self.use_markdown {
|
|
self.md_buffer.push_str(&clean);
|
|
} else {
|
|
for ch in clean.chars() {
|
|
if ch == '\n' {
|
|
let line = std::mem::take(&mut self.current_line);
|
|
self.lines.push(Line::styled(line, Style::default().fg(self.current_color)));
|
|
self.markers.push(Marker::None);
|
|
self.evict();
|
|
} else {
|
|
self.current_line.push(ch);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn push_line_with_marker(&mut self, line: String, color: Color, marker: Marker) {
|
|
self.flush_pending();
|
|
self.lines.push(Line::styled(strip_ansi(&line), Style::default().fg(color)));
|
|
self.markers.push(marker);
|
|
self.evict();
|
|
}
|
|
|
|
fn pop_line(&mut self) {
|
|
self.lines.pop();
|
|
self.markers.pop();
|
|
self.scroll.invalidate_from(self.lines.len());
|
|
}
|
|
|
|
fn scroll_up(&mut self, n: u16) {
|
|
self.scroll.scroll_up(n);
|
|
}
|
|
|
|
fn scroll_down(&mut self, n: u16) {
|
|
self.scroll.scroll_down(n);
|
|
}
|
|
|
|
fn all_lines(&self) -> Vec<Line<'static>> {
|
|
let (lines, _) = self.all_lines_with_markers();
|
|
lines
|
|
}
|
|
|
|
fn all_lines_with_markers(&self) -> (Vec<Line<'static>>, Vec<Marker>) {
|
|
let mut lines: Vec<Line<'static>> = self.lines.clone();
|
|
let mut markers: Vec<Marker> = self.markers.clone();
|
|
if self.use_markdown && !self.md_buffer.is_empty() {
|
|
let parsed = parse_markdown(&self.md_buffer);
|
|
let count = parsed.len();
|
|
lines.extend(parsed);
|
|
if count > 0 {
|
|
markers.push(self.pending_marker);
|
|
markers.extend(std::iter::repeat(Marker::None).take(count - 1));
|
|
}
|
|
} else if !self.current_line.is_empty() {
|
|
lines.push(Line::styled(self.current_line.clone(), Style::default().fg(self.current_color)));
|
|
markers.push(self.pending_marker);
|
|
}
|
|
(lines, markers)
|
|
}
|
|
|
|
/// Convert mouse coordinates (relative to pane) to line/column position.
|
|
fn mouse_to_position(&self, mouse_x: u16, mouse_y: u16) -> Option<(usize, usize)> {
|
|
let (lines, _) = self.all_lines_with_markers();
|
|
self.scroll.screen_to_item(mouse_x, mouse_y, &lines)
|
|
}
|
|
|
|
/// Set the selection start position.
|
|
fn start_selection(&mut self, line: usize, col: usize) {
|
|
self.selection = Some(Selection::new(line, col));
|
|
}
|
|
|
|
/// Update the selection end position.
|
|
fn extend_selection(&mut self, line: usize, col: usize) {
|
|
if let Some(ref mut sel) = self.selection {
|
|
sel.extend(line, col);
|
|
}
|
|
}
|
|
|
|
/// Get the selected text, or None if nothing is selected.
|
|
fn get_selection(&self) -> Option<String> {
|
|
let (lines, _) = self.all_lines_with_markers();
|
|
self.selection.as_ref().map(|sel| sel.text(&lines))
|
|
}
|
|
}
|
|
|
|
pub(crate) struct InteractScreen {
|
|
autonomous: PaneState,
|
|
conversation: PaneState,
|
|
tools: PaneState,
|
|
textarea: tui_textarea::TextArea<'static>,
|
|
input_history: Vec<String>,
|
|
history_index: Option<usize>,
|
|
active_pane: ActivePane,
|
|
pane_areas: [Rect; 3],
|
|
turn_started: Option<std::time::Instant>,
|
|
call_started: Option<std::time::Instant>,
|
|
call_timeout_secs: u64,
|
|
// State sync with agent — double buffer
|
|
last_generation: u64,
|
|
last_entries: Vec<AstNode>,
|
|
pending_display_count: usize,
|
|
/// Reference to agent for state sync
|
|
agent: std::sync::Arc<crate::agent::Agent>,
|
|
shared_mind: std::sync::Arc<crate::mind::SharedMindState>,
|
|
mind_tx: tokio::sync::mpsc::UnboundedSender<crate::mind::MindCommand>,
|
|
}
|
|
|
|
impl InteractScreen {
|
|
pub fn new(
|
|
agent: std::sync::Arc<crate::agent::Agent>,
|
|
shared_mind: std::sync::Arc<crate::mind::SharedMindState>,
|
|
mind_tx: tokio::sync::mpsc::UnboundedSender<crate::mind::MindCommand>,
|
|
) -> 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],
|
|
turn_started: None,
|
|
call_started: None,
|
|
call_timeout_secs: 60,
|
|
last_generation: 0,
|
|
last_entries: Vec::new(),
|
|
pending_display_count: 0,
|
|
agent,
|
|
shared_mind,
|
|
mind_tx,
|
|
}
|
|
}
|
|
|
|
fn route_node(node: &AstNode) -> Vec<(PaneTarget, String, Marker)> {
|
|
match node {
|
|
AstNode::Leaf(leaf) => {
|
|
let text = leaf.body().text().to_string();
|
|
match leaf.body() {
|
|
NodeBody::Memory { .. } | NodeBody::Thinking(_)
|
|
| NodeBody::Log(_) | NodeBody::Dmn(_) => vec![],
|
|
NodeBody::Content(_) => {
|
|
if text.is_empty() || text.starts_with("<system-reminder>") { vec![] }
|
|
else { vec![(PaneTarget::Conversation, text, Marker::User)] }
|
|
}
|
|
NodeBody::ToolCall { name, arguments } => {
|
|
let line = format!("[{}] {}", name, arguments.chars().take(80).collect::<String>());
|
|
vec![(PaneTarget::Tools, line, Marker::None)]
|
|
}
|
|
NodeBody::ToolResult(t) => {
|
|
if t.is_empty() { vec![] }
|
|
else { vec![(PaneTarget::ToolResult, text, Marker::None)] }
|
|
}
|
|
}
|
|
}
|
|
AstNode::Branch { role, children, .. } => {
|
|
match role {
|
|
Role::User => {
|
|
let text: String = children.iter()
|
|
.filter_map(|c| c.leaf())
|
|
.filter(|l| matches!(l.body(), NodeBody::Content(_)))
|
|
.map(|l| l.body().text())
|
|
.collect::<Vec<_>>()
|
|
.join("");
|
|
if text.is_empty() || text.starts_with("<system-reminder>") { vec![] }
|
|
else { vec![(PaneTarget::Conversation, text, Marker::User)] }
|
|
}
|
|
Role::Assistant => {
|
|
let mut items = Vec::new();
|
|
for child in children {
|
|
items.extend(Self::route_node(child));
|
|
}
|
|
// Re-tag content as assistant
|
|
for item in &mut items {
|
|
if item.0 == PaneTarget::Conversation {
|
|
item.0 = PaneTarget::ConversationAssistant;
|
|
item.2 = Marker::Assistant;
|
|
}
|
|
}
|
|
items
|
|
}
|
|
Role::System => vec![],
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn push_routed(&mut self, node: &AstNode) {
|
|
for (target, text, marker) in Self::route_node(node) {
|
|
match target {
|
|
PaneTarget::Conversation => {
|
|
self.conversation.current_color = Color::Cyan;
|
|
self.conversation.append_text(&text);
|
|
self.conversation.pending_marker = marker;
|
|
self.conversation.flush_pending();
|
|
},
|
|
PaneTarget::ConversationAssistant => {
|
|
self.conversation.current_color = Color::Reset;
|
|
self.conversation.append_text(&text);
|
|
self.conversation.pending_marker = marker;
|
|
self.conversation.flush_pending();
|
|
},
|
|
PaneTarget::Tools =>
|
|
self.tools.push_line(text, Color::Yellow),
|
|
PaneTarget::ToolResult => {
|
|
for line in text.lines().take(20) {
|
|
self.tools.push_line(format!(" {}", line), Color::DarkGray);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pop_routed(&mut self, node: &AstNode) {
|
|
for (target, _, _) in Self::route_node(node) {
|
|
match target {
|
|
PaneTarget::Conversation | PaneTarget::ConversationAssistant
|
|
=> self.conversation.pop_line(),
|
|
PaneTarget::Tools | PaneTarget::ToolResult
|
|
=> self.tools.pop_line(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sync_from_agent(&mut self) {
|
|
for _ in 0..self.pending_display_count {
|
|
self.conversation.pop_line();
|
|
}
|
|
self.pending_display_count = 0;
|
|
|
|
let (generation, entries) = {
|
|
let st = match self.agent.state.try_lock() {
|
|
Ok(st) => st,
|
|
Err(_) => return,
|
|
};
|
|
let generation = st.generation;
|
|
drop(st);
|
|
let ctx = match self.agent.context.try_lock() {
|
|
Ok(ctx) => ctx,
|
|
Err(_) => return,
|
|
};
|
|
(generation, ctx.conversation().to_vec())
|
|
};
|
|
|
|
// Full reset on generation change
|
|
if generation != self.last_generation {
|
|
self.conversation = PaneState::new(true);
|
|
self.autonomous = PaneState::new(true);
|
|
self.tools = PaneState::new(false);
|
|
self.last_entries.clear();
|
|
}
|
|
|
|
// Detect changed entries (streaming updates mutate the last entry)
|
|
// Walk backwards from the end, pop any that differ
|
|
let mut pop_from = self.last_entries.len();
|
|
for i in (0..self.last_entries.len()).rev() {
|
|
if i >= entries.len() {
|
|
pop_from = i;
|
|
continue;
|
|
}
|
|
// Compare token count as a cheap change detector
|
|
if self.last_entries[i].tokens() != entries[i].tokens() {
|
|
pop_from = i;
|
|
} else {
|
|
break; // entries before this haven't changed
|
|
}
|
|
}
|
|
while self.last_entries.len() > pop_from {
|
|
let popped = self.last_entries.pop().unwrap();
|
|
self.pop_routed(&popped);
|
|
}
|
|
|
|
// Push new/changed entries
|
|
for node in entries.iter().skip(self.last_entries.len()) {
|
|
self.push_routed(node);
|
|
self.last_entries.push(node.clone());
|
|
}
|
|
|
|
self.last_generation = generation;
|
|
|
|
// Display pending input (queued in Mind, not yet accepted)
|
|
let mind = self.shared_mind.lock().unwrap();
|
|
for input in &mind.input {
|
|
self.conversation.push_line_with_marker(
|
|
input.clone(), Color::DarkGray, Marker::User,
|
|
);
|
|
self.pending_display_count += 1;
|
|
}
|
|
}
|
|
|
|
/// Dispatch user input — slash commands or conversation.
|
|
fn dispatch_input(&self, input: &str, app: &mut App) {
|
|
let input = input.trim();
|
|
if input.is_empty() { return; }
|
|
|
|
if input == "/quit" || input == "/exit" {
|
|
app.should_quit = true;
|
|
return;
|
|
}
|
|
|
|
if input.starts_with('/') {
|
|
if let Some(cmd) = dispatch_command(input) {
|
|
(cmd.handler)(self, &input[cmd.name.len()..].trim_start());
|
|
} else {
|
|
if let Ok(mut ag) = self.agent.state.try_lock() {
|
|
ag.notify(format!("unknown: {}", input.split_whitespace().next().unwrap_or(input)));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Regular input → queue to Mind, then wake it
|
|
self.shared_mind.lock().unwrap().input.push(input.to_string());
|
|
let _ = self.mind_tx.send(MindCommand::None);
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
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 };
|
|
let rel_x = x.saturating_sub(area.x);
|
|
let rel_y = y.saturating_sub(area.y);
|
|
self.selection_event(i, rel_x, rel_y, true);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
MouseEventKind::Drag(MouseButton::Left) => {
|
|
let (x, y) = (mouse.column, mouse.row);
|
|
let i = match self.active_pane { ActivePane::Autonomous => 0, ActivePane::Conversation => 1, ActivePane::Tools => 2 };
|
|
let area = self.pane_areas[i];
|
|
if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height {
|
|
let rel_x = x.saturating_sub(area.x);
|
|
let rel_y = y.saturating_sub(area.y);
|
|
self.selection_event(i, rel_x, rel_y, false);
|
|
}
|
|
}
|
|
MouseEventKind::Up(MouseButton::Left) => {
|
|
self.copy_selection_to_clipboard();
|
|
}
|
|
MouseEventKind::Down(MouseButton::Middle) => {
|
|
self.paste_from_selection();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Copy the current selection to the clipboard via OSC 52.
|
|
fn copy_selection_to_clipboard(&self) {
|
|
let text = match self.active_pane {
|
|
ActivePane::Autonomous => self.autonomous.get_selection(),
|
|
ActivePane::Conversation => self.conversation.get_selection(),
|
|
ActivePane::Tools => self.tools.get_selection(),
|
|
};
|
|
if let Some(ref selected_text) = text {
|
|
if selected_text.is_empty() { return; }
|
|
// OSC 52 clipboard copy
|
|
use std::io::Write;
|
|
use base64::Engine;
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(selected_text);
|
|
let mut stdout = std::io::stdout().lock();
|
|
let _ = write!(stdout, "\x1b]52;c;{}\x07", encoded);
|
|
let _ = stdout.flush();
|
|
}
|
|
}
|
|
|
|
/// Paste from tmux buffer via middle-click.
|
|
fn paste_from_selection(&mut self) {
|
|
let result = std::process::Command::new("tmux")
|
|
.args(["save-buffer", "-"]).output();
|
|
if let Ok(output) = result {
|
|
if output.status.success() {
|
|
let text = String::from_utf8_lossy(&output.stdout).into_owned();
|
|
if !text.is_empty() {
|
|
self.textarea.insert_str(&text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pane_mut(&mut self, idx: usize) -> &mut PaneState {
|
|
match idx { 0 => &mut self.autonomous, 1 => &mut self.conversation, _ => &mut self.tools }
|
|
}
|
|
|
|
fn selection_event(&mut self, pane_idx: usize, rel_x: u16, rel_y: u16, start: bool) {
|
|
let pane = self.pane_mut(pane_idx);
|
|
if let Some((line, col)) = pane.mouse_to_position(rel_x, rel_y) {
|
|
if start {
|
|
pane.start_selection(line, col);
|
|
} else {
|
|
pane.extend_selection(line, col);
|
|
}
|
|
}
|
|
self.copy_selection_to_clipboard();
|
|
}
|
|
|
|
/// Draw the main (F1) screen — four-pane layout with status bar.
|
|
fn draw_main(&mut self, frame: &mut Frame, size: Rect, app: &App) {
|
|
// Main layout: content area + active tools overlay + status bar
|
|
let st_guard = app.agent.state.try_lock().ok();
|
|
let tool_lines = st_guard.as_ref()
|
|
.map(|st| st.active_tools.len() as u16).unwrap_or(0);
|
|
let main_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Min(3), // content area
|
|
Constraint::Length(tool_lines), // active tools (0 when empty)
|
|
Constraint::Length(1), // status bar
|
|
])
|
|
.split(size);
|
|
|
|
let content_area = main_chunks[0];
|
|
let tools_overlay_area = main_chunks[1];
|
|
let status_area = main_chunks[2];
|
|
|
|
// Content: left column (55%) + right column (45%)
|
|
let columns = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(55),
|
|
Constraint::Percentage(45),
|
|
])
|
|
.split(content_area);
|
|
|
|
let left_col = columns[0];
|
|
let right_col = columns[1];
|
|
|
|
// Left column: autonomous (35%) + conversation (65%)
|
|
let left_panes = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage(35),
|
|
Constraint::Percentage(65),
|
|
])
|
|
.split(left_col);
|
|
|
|
let auto_area = left_panes[0];
|
|
let conv_area = left_panes[1];
|
|
|
|
// Store pane areas for mouse click detection
|
|
self.pane_areas = [auto_area, conv_area, right_col];
|
|
|
|
// Draw autonomous pane
|
|
let auto_active = self.active_pane == ActivePane::Autonomous;
|
|
draw_pane(frame, auto_area, "autonomous", &mut self.autonomous, auto_active,
|
|
Some(&screen_legend()));
|
|
|
|
// Draw tools pane
|
|
let tools_active = self.active_pane == ActivePane::Tools;
|
|
draw_pane(frame, right_col, "tools", &mut self.tools, tools_active, None);
|
|
|
|
// Draw conversation pane (with input line)
|
|
let conv_active = self.active_pane == ActivePane::Conversation;
|
|
|
|
// Input area: compute visual height, split, render gutter + textarea
|
|
let input_text = self.textarea.lines().join("\n");
|
|
let input_para_measure = Paragraph::new(input_text).wrap(Wrap { trim: false });
|
|
let input_line_count = (input_para_measure.line_count(conv_area.width.saturating_sub(5)) as u16)
|
|
.max(1)
|
|
.min(5);
|
|
|
|
let conv_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Min(1), // conversation text
|
|
Constraint::Length(input_line_count), // input area
|
|
])
|
|
.split(conv_area);
|
|
|
|
let text_area_rect = conv_chunks[0];
|
|
let input_area = conv_chunks[1];
|
|
|
|
draw_conversation_pane(frame, text_area_rect, &mut self.conversation, conv_active);
|
|
|
|
// " > " gutter + textarea, aligned with conversation messages
|
|
let input_chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Length(3), // " > " gutter
|
|
Constraint::Min(1), // textarea
|
|
])
|
|
.split(input_area);
|
|
|
|
let gutter = Paragraph::new(Line::styled(
|
|
" > ",
|
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
|
));
|
|
frame.render_widget(gutter, input_chunks[0]);
|
|
frame.render_widget(&self.textarea, input_chunks[1]);
|
|
|
|
if let Some(ref st) = st_guard {
|
|
if !st.active_tools.is_empty() {
|
|
let tool_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::DIM);
|
|
let tool_text: Vec<Line> = st.active_tools.iter().map(|t| {
|
|
let elapsed = t.started.elapsed().as_secs();
|
|
let line = if t.detail.is_empty() {
|
|
format!(" [{}] ({}s)", t.name, elapsed)
|
|
} else {
|
|
format!(" [{}] {} ({}s)", t.name, t.detail, elapsed)
|
|
};
|
|
Line::styled(line, tool_style)
|
|
}).collect();
|
|
let tool_para = Paragraph::new(tool_text);
|
|
frame.render_widget(tool_para, tools_overlay_area);
|
|
}}
|
|
|
|
// Draw status bar with live activity indicator
|
|
let timer = if !app.activity.is_empty() {
|
|
let elapsed = app.activity_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
|
format!(" {}s", elapsed)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let tools_info = if app.status.turn_tools > 0 {
|
|
format!(" ({}t)", app.status.turn_tools)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let activity_part = if app.activity.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" | {}{}{}", app.activity, tools_info, timer)
|
|
};
|
|
|
|
let budget_part = if app.status.context_budget.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" [{}]", app.status.context_budget)
|
|
};
|
|
|
|
let left_status = format!(
|
|
" {} | {}/{} dmn | {}K tok in{}{}",
|
|
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 app.running_processes > 0 {
|
|
format!(" {}proc", app.running_processes)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let reason_indicator = if app.reasoning_effort != "none" {
|
|
format!(" reason:{}", app.reasoning_effort)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let right_legend = format!(
|
|
"{}{} ^P:pause ^R:reason ^K:kill | {} ",
|
|
reason_indicator,
|
|
proc_indicator,
|
|
app.status.model,
|
|
);
|
|
|
|
// Pad the middle to fill the status bar
|
|
let total_width = status_area.width as usize;
|
|
let used = left_status.len() + right_legend.len();
|
|
let padding = if total_width > used {
|
|
" ".repeat(total_width - used)
|
|
} else {
|
|
" ".to_string()
|
|
};
|
|
|
|
let status = Paragraph::new(Line::from(vec![
|
|
Span::styled(&left_status, Style::default().fg(Color::White).bg(Color::DarkGray)),
|
|
Span::styled(padding, Style::default().bg(Color::DarkGray)),
|
|
Span::styled(
|
|
right_legend,
|
|
Style::default().fg(Color::DarkGray).bg(Color::Gray),
|
|
),
|
|
]));
|
|
|
|
frame.render_widget(status, status_area);
|
|
}
|
|
}
|
|
|
|
impl ScreenView for InteractScreen {
|
|
fn label(&self) -> &'static str { "interact" }
|
|
|
|
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
|
events: &[ratatui::crossterm::event::Event], app: &mut App) {
|
|
use ratatui::crossterm::event::Event;
|
|
// Handle events
|
|
for event in events {
|
|
match event {
|
|
Event::Key(key) if key.kind == ratatui::crossterm::event::KeyEventKind::Press => {
|
|
match key.code {
|
|
KeyCode::Esc => { let _ = self.mind_tx.send(crate::mind::MindCommand::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.dispatch_input(&input, app);
|
|
self.textarea = new_textarea(vec![String::new()]);
|
|
}
|
|
}
|
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT) => {
|
|
// Ctrl+Shift+C: copy selection
|
|
self.copy_selection_to_clipboard();
|
|
}
|
|
// Paste: terminal handles Ctrl+Shift+V natively via bracketed paste
|
|
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); }
|
|
}
|
|
}
|
|
Event::Mouse(mouse) => { self.handle_mouse(*mouse); }
|
|
Event::Paste(text) => {
|
|
self.textarea.insert_str(text);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Sync state from agent
|
|
self.sync_from_agent();
|
|
|
|
// Read status from agent + mind state
|
|
if let Ok(mut st) = self.agent.state.try_lock() {
|
|
st.expire_activities();
|
|
app.status.prompt_tokens = st.last_prompt_tokens;
|
|
app.status.model = self.agent.model().to_string();
|
|
app.activity = st.activities.last()
|
|
.map(|a| a.label.clone())
|
|
.unwrap_or_default();
|
|
app.activity_started = st.activities.last()
|
|
.map(|a| a.started);
|
|
}
|
|
if let Ok(ctx) = self.agent.context.try_lock() {
|
|
let window = crate::agent::context::context_window();
|
|
if window > 0 {
|
|
let sys = ctx.system().iter().map(|n| n.tokens()).sum::<usize>();
|
|
let id = ctx.identity().iter().map(|n| n.tokens()).sum::<usize>();
|
|
let jnl = ctx.journal().iter().map(|n| n.tokens()).sum::<usize>();
|
|
let mut mem = 0usize;
|
|
let mut conv = 0usize;
|
|
for n in ctx.conversation() {
|
|
let t = n.tokens();
|
|
if matches!(n, AstNode::Leaf(l) if matches!(l.body(), NodeBody::Memory { .. })) {
|
|
mem += t;
|
|
} else {
|
|
conv += t;
|
|
}
|
|
}
|
|
let used = sys + id + jnl + mem + conv;
|
|
let free = window.saturating_sub(used);
|
|
let pct = |n: usize| if n == 0 { 0 } else { ((n * 100) / window).max(1) };
|
|
app.status.context_budget = format!(
|
|
"sys:{}% id:{}% jnl:{}% mem:{}% conv:{}% free:{}%",
|
|
pct(sys), pct(id), pct(jnl), pct(mem), pct(conv), pct(free),
|
|
);
|
|
}
|
|
}
|
|
{
|
|
let mind = self.shared_mind.lock().unwrap();
|
|
app.status.dmn_state = mind.dmn.label().to_string();
|
|
app.status.dmn_turns = mind.dmn_turns;
|
|
app.status.dmn_max_turns = mind.max_dmn_turns;
|
|
}
|
|
|
|
// Draw
|
|
self.draw_main(frame, area, app);
|
|
}
|
|
|
|
}
|
|
|
|
/// Draw the conversation pane with a two-column layout: marker gutter + text.
|
|
/// The gutter shows a marker at turn boundaries, aligned with the input gutter.
|
|
fn draw_conversation_pane(
|
|
frame: &mut Frame,
|
|
area: Rect,
|
|
pane: &mut PaneState,
|
|
is_active: bool,
|
|
) {
|
|
use super::scroll_pane::visible_range;
|
|
|
|
let border_style = if is_active {
|
|
Style::default().fg(Color::Cyan)
|
|
} else {
|
|
Style::default().fg(Color::DarkGray)
|
|
};
|
|
|
|
let block = Block::default()
|
|
.title(" conversation ")
|
|
.borders(Borders::ALL)
|
|
.border_style(border_style);
|
|
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
if inner.width < 5 || inner.height == 0 {
|
|
return;
|
|
}
|
|
|
|
let cols = Layout::horizontal([Constraint::Length(2), Constraint::Min(1)]).split(inner);
|
|
let gutter_area = cols[0];
|
|
let text_area = cols[1];
|
|
let text_width = text_area.width;
|
|
|
|
let (lines, markers) = pane.all_lines_with_markers();
|
|
pane.scroll.ensure_heights_for_lines(&lines, text_width);
|
|
|
|
if !pane.scroll.pinned {
|
|
pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner.height);
|
|
}
|
|
pane.scroll.viewport_height = inner.height;
|
|
pane.scroll.offset = pane.scroll.offset.min(pane.scroll.total_visual.saturating_sub(inner.height));
|
|
|
|
let heights = pane.scroll.heights();
|
|
let (first, sub_scroll, last) = visible_range(heights, pane.scroll.offset, inner.height);
|
|
|
|
// Apply selection highlighting to visible lines
|
|
let visible_lines: Vec<Line<'static>> = if let Some(ref sel) = pane.selection {
|
|
let (sl, sc, el, ec) = sel.range();
|
|
(first..last).map(|i| {
|
|
let line = &lines[i];
|
|
if i >= sl && i <= el {
|
|
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
|
let start_col = if i == sl { sc } else { 0 };
|
|
let end_col = if i == el { ec } else { line_text.len() };
|
|
if start_col < end_col {
|
|
let before = if start_col > 0 { &line_text[..start_col] } else { "" };
|
|
let selected = &line_text[start_col..end_col];
|
|
let after = if end_col < line_text.len() { &line_text[end_col..] } else { "" };
|
|
let mut spans = Vec::new();
|
|
if !before.is_empty() { spans.push(Span::raw(before.to_string())); }
|
|
spans.push(Span::styled(selected.to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)));
|
|
if !after.is_empty() { spans.push(Span::raw(after.to_string())); }
|
|
Line::from(spans).style(line.style).alignment(line.alignment.unwrap_or(ratatui::layout::Alignment::Left))
|
|
} else {
|
|
line.clone()
|
|
}
|
|
} else {
|
|
line.clone()
|
|
}
|
|
}).collect()
|
|
} else {
|
|
lines[first..last].to_vec()
|
|
};
|
|
|
|
let text_para = Paragraph::new(visible_lines)
|
|
.wrap(Wrap { trim: false })
|
|
.scroll((sub_scroll, 0));
|
|
frame.render_widget(text_para, text_area);
|
|
|
|
// Build gutter for the visible slice
|
|
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
|
|
for i in first..last {
|
|
gutter_lines.push(match markers[i] {
|
|
Marker::User => Line::styled("● ", Style::default().fg(Color::Cyan)),
|
|
Marker::Assistant => Line::styled("● ", Style::default().fg(Color::Magenta)),
|
|
Marker::None => Line::raw(""),
|
|
});
|
|
for _ in 1..heights[i] {
|
|
gutter_lines.push(Line::raw(""));
|
|
}
|
|
}
|
|
|
|
let gutter_para = Paragraph::new(gutter_lines).scroll((sub_scroll, 0));
|
|
frame.render_widget(gutter_para, gutter_area);
|
|
}
|
|
|
|
/// Draw a scrollable text pane (free function to avoid borrow issues).
|
|
fn draw_pane(
|
|
frame: &mut Frame,
|
|
area: Rect,
|
|
title: &str,
|
|
pane: &mut PaneState,
|
|
is_active: bool,
|
|
left_title: Option<&str>,
|
|
) {
|
|
use super::scroll_pane::visible_range;
|
|
|
|
let border_style = if is_active {
|
|
Style::default().fg(Color::Cyan)
|
|
} else {
|
|
Style::default().fg(Color::DarkGray)
|
|
};
|
|
|
|
let mut block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(border_style);
|
|
if let Some(left) = left_title {
|
|
block = block
|
|
.title_top(Line::from(left).left_aligned())
|
|
.title_top(Line::from(format!(" {} ", title)).right_aligned());
|
|
} else {
|
|
block = block.title(format!(" {} ", title));
|
|
}
|
|
|
|
let text_width = area.width.saturating_sub(2);
|
|
let inner_height = area.height.saturating_sub(2);
|
|
let lines = pane.all_lines();
|
|
pane.scroll.ensure_heights_for_lines(&lines, text_width);
|
|
|
|
if !pane.scroll.pinned {
|
|
pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner_height);
|
|
}
|
|
pane.scroll.viewport_height = inner_height;
|
|
pane.scroll.offset = pane.scroll.offset.min(pane.scroll.total_visual.saturating_sub(inner_height));
|
|
|
|
let (first, sub_scroll, last) = visible_range(pane.scroll.heights(), pane.scroll.offset, inner_height);
|
|
|
|
let paragraph = Paragraph::new(lines[first..last].to_vec())
|
|
.block(block.clone())
|
|
.wrap(Wrap { trim: false })
|
|
.scroll((sub_scroll, 0));
|
|
frame.render_widget(paragraph, area);
|
|
}
|