consciousness/src/user/tui/mod.rs
ProofOfConcept 8e66f0a66c wire channel list RPC into consciousness F5 screen
fetch_all_channels() connects to each daemon socket and calls
list() via capnp RPC. Runs on a dedicated thread (capnp uses Rc).
Results sent back via mpsc channel, TUI reads cached state.

Fetched at startup and when switching to F5 thalamus screen.
Also calls ensure_running() to restart dead daemons.

Co-Developed-By: Kent Overstreet <kent.overstreet@linux.dev>
2026-04-03 19:53:23 -04:00

903 lines
35 KiB
Rust

// tui/ — Terminal UI with split panes
//
// Four-pane layout:
// Left top: Autonomous output (DMN annotations + model prose)
// Left bottom: Conversation (user input + model responses)
// Right: Tool activity (tool calls with full results)
// Bottom: Status bar (DMN state, turns, tokens, model)
//
// Uses ratatui + crossterm. The App struct holds all TUI state and
// handles rendering. Input is processed from crossterm key events.
//
// Screen files:
// main_screen.rs — F1 interact (conversation, tools, autonomous)
// context_screen.rs — F2 conscious (context window, model info)
// subconscious_screen.rs — F3 subconscious (consolidation agents)
// unconscious_screen.rs — F4 unconscious (memory daemon status)
mod main_screen;
mod context_screen;
mod subconscious_screen;
mod unconscious_screen;
mod thalamus_screen;
pub(crate) const SCREEN_LEGEND: &str = " F1=interact F2=conscious F3=subconscious F4=unconscious F5=thalamus ";
/// Subconscious agents — interact with conscious context
pub(crate) const SUBCONSCIOUS_AGENTS: &[&str] = &["surface-observe", "journal", "reflect"];
/// Unconscious agents — background consolidation
#[allow(dead_code)]
pub(crate) const UNCONSCIOUS_AGENTS: &[&str] = &["linker", "organize", "distill", "split"];
use crossterm::{
event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
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};
/// Strip ANSI escape sequences (color codes, cursor movement, etc.)
/// from text so tool output renders cleanly in the TUI.
pub(crate) 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' {
// CSI sequence: ESC [ ... final_byte
if chars.peek() == Some(&'[') {
chars.next(); // consume '['
// Consume parameter bytes (0x30-0x3F), intermediate (0x20-0x2F),
// then one final byte (0x40-0x7E)
while let Some(&c) = chars.peek() {
if c.is_ascii() && (0x20..=0x3F).contains(&(c as u8)) {
chars.next();
} else {
break;
}
}
// Final byte
if let Some(&c) = chars.peek() {
if c.is_ascii() && (0x40..=0x7E).contains(&(c as u8)) {
chars.next();
}
}
}
// Other escape sequences (ESC + single char)
else if let Some(&c) = chars.peek() {
if c.is_ascii() && (0x40..=0x5F).contains(&(c as u8)) {
chars.next();
}
}
} else {
out.push(ch);
}
}
out
}
/// Check if a Unicode character is zero-width (invisible but takes space
/// in the character count, causing rendering artifacts like `[]`).
pub(crate) fn is_zero_width(ch: char) -> bool {
matches!(ch,
'\u{200B}'..='\u{200F}' | // zero-width space, joiners, directional marks
'\u{2028}'..='\u{202F}' | // line/paragraph separators, embedding
'\u{2060}'..='\u{2069}' | // word joiner, invisible operators
'\u{FEFF}' // byte order mark
)
}
/// Which pane receives scroll keys.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ActivePane {
Autonomous,
Conversation,
Tools,
}
/// Maximum lines kept per pane. Older lines are evicted to prevent
/// unbounded memory growth during long sessions.
const MAX_PANE_LINES: usize = 10_000;
/// Turn marker for the conversation pane gutter.
#[derive(Clone, Copy, PartialEq, Default)]
pub(crate) enum Marker {
#[default]
None,
User,
Assistant,
}
/// A scrollable text pane with auto-scroll behavior.
///
/// Scroll offset is in visual (wrapped) lines so that auto-scroll
/// correctly tracks the bottom even when long lines wrap.
pub(crate) struct PaneState {
pub(crate) lines: Vec<Line<'static>>,
/// Turn markers — parallel to lines, same length.
pub(crate) markers: Vec<Marker>,
/// Current line being built (no trailing newline yet) — plain mode only.
pub(crate) current_line: String,
/// Color applied to streaming text (set before append_text) — plain mode only.
pub(crate) current_color: Color,
/// Raw markdown text of the current streaming response.
pub(crate) md_buffer: String,
/// Whether this pane parses streaming text as markdown.
pub(crate) use_markdown: bool,
/// Marker to apply to the next line pushed (for turn start tracking).
pub(crate) pending_marker: Marker,
/// Scroll offset in visual (wrapped) lines from the top.
pub(crate) scroll: u16,
/// Whether the user has scrolled away from the bottom.
pub(crate) pinned: bool,
/// Last known total visual lines (set during draw by Paragraph::line_count).
pub(crate) last_total_lines: u16,
/// Last known inner height (set during draw).
pub(crate) last_height: u16,
}
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: 0,
pinned: false,
last_total_lines: 0,
last_height: 20,
}
}
/// Evict old lines if we're over the cap.
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);
// Approximate: reduce scroll by the wrapped height of evicted lines.
// Not perfectly accurate but prevents scroll from jumping wildly.
self.scroll = self.scroll.saturating_sub(excess as u16);
}
}
/// Append text, splitting on newlines. Strips ANSI escapes.
/// In markdown mode, raw text accumulates in md_buffer for
/// live parsing during render. In plain mode, character-by-character
/// processing builds lines with current_color.
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);
} else if ch == '\t' {
self.current_line.push_str(" ");
} else if ch.is_control() || is_zero_width(ch) {
// Skip control chars and zero-width Unicode
} else {
self.current_line.push(ch);
}
}
}
self.evict();
}
/// Finalize any pending content (markdown buffer or current line).
pub(crate) 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));
}
}
/// Push a complete line with a color. Flushes any pending
/// markdown or plain-text content first.
fn push_line(&mut self, line: String, color: Color) {
self.push_line_with_marker(line, color, Marker::None);
}
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();
}
/// Scroll up by n visual lines, pinning if we move away from bottom.
fn scroll_up(&mut self, n: u16) {
self.scroll = self.scroll.saturating_sub(n);
self.pinned = true;
}
/// Scroll down by n visual lines. Un-pin if we reach bottom.
fn scroll_down(&mut self, n: u16) {
let max = self.last_total_lines.saturating_sub(self.last_height);
self.scroll = (self.scroll + n).min(max);
if self.scroll >= max {
self.pinned = false;
}
}
/// Get all lines as ratatui Lines. Includes finalized lines plus
/// any pending content (live-parsed markdown or in-progress plain line).
/// Scrolling is handled by Paragraph::scroll().
pub(crate) fn all_lines(&self) -> Vec<Line<'static>> {
let (lines, _) = self.all_lines_with_markers();
lines
}
/// Get lines and their markers together. Used by the two-column
/// conversation renderer to know where to place gutter markers.
pub(crate) 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)
}
}
/// Create a new textarea with standard settings (word wrap, no cursor line highlight).
pub(crate) 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
}
/// Parse markdown text into owned ratatui Lines.
pub(crate) 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()
}
/// A tool call currently in flight — shown above the status bar.
pub(crate) struct ActiveTool {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) detail: String,
pub(crate) started: std::time::Instant,
}
/// Main TUI application state.
pub struct App {
pub(crate) autonomous: PaneState,
pub(crate) conversation: PaneState,
pub(crate) tools: PaneState,
pub(crate) status: StatusInfo,
/// Live activity indicator ("thinking...", "calling: bash", etc).
pub(crate) activity: String,
/// When the current turn started (for elapsed timer).
pub(crate) turn_started: Option<std::time::Instant>,
/// When the current LLM call started (for per-call timer).
pub(crate) call_started: Option<std::time::Instant>,
/// Stream timeout for the current call (for display).
pub(crate) call_timeout_secs: u64,
/// Whether to emit a marker before the next assistant TextDelta.
pub(crate) needs_assistant_marker: bool,
/// Number of running child processes (updated by main loop).
pub running_processes: u32,
/// Current reasoning effort level (for status display).
pub reasoning_effort: String,
pub(crate) active_tools: Vec<ActiveTool>,
pub(crate) active_pane: ActivePane,
/// User input editor (handles wrapping, cursor positioning).
pub textarea: tui_textarea::TextArea<'static>,
/// Input history for up/down navigation.
input_history: Vec<String>,
history_index: Option<usize>,
/// Whether to quit.
pub should_quit: bool,
/// Submitted input lines waiting to be consumed.
pub submitted: Vec<String>,
/// Pending hotkey actions for the main loop to process.
pub hotkey_actions: Vec<HotkeyAction>,
/// Pane areas from last draw (for mouse click -> pane selection).
pub(crate) pane_areas: [Rect; 3], // [autonomous, conversation, tools]
/// Active screen (F1-F4).
pub screen: Screen,
/// Debug screen scroll offset.
pub(crate) debug_scroll: u16,
/// Index of selected context section in debug view (for expand/collapse).
pub(crate) debug_selected: Option<usize>,
/// Which context section indices are expanded.
pub(crate) debug_expanded: std::collections::HashSet<usize>,
/// Context loading info for the debug screen.
pub(crate) context_info: Option<ContextInfo>,
/// Live context state — shared with agent, read directly for debug screen.
pub(crate) shared_context: SharedContextState,
/// Agent screen: selected agent index.
pub(crate) agent_selected: usize,
/// Agent screen: viewing log for selected agent.
pub(crate) agent_log_view: bool,
/// Agent state from last cycle update.
pub(crate) agent_state: Vec<crate::subconscious::subconscious::AgentSnapshot>,
/// Cached channel info for F5 screen (refreshed on status tick).
pub(crate) channel_status: Vec<ChannelStatus>,
/// Cached idle state for F5 screen.
pub(crate) idle_info: Option<IdleInfo>,
}
/// Snapshot of thalamus idle state for display.
#[derive(Clone)]
pub(crate) struct IdleInfo {
pub user_present: bool,
pub since_activity: f64,
pub activity_ewma: f64,
pub block_reason: String,
pub dreaming: bool,
pub sleeping: bool,
}
/// Channel info for display on F5 screen.
#[derive(Clone)]
pub(crate) struct ChannelStatus {
pub name: String,
pub connected: bool,
pub unread: u32,
}
/// Screens toggled by F-keys.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Screen {
/// F1 — conversation
Interact,
/// F2 — context window, model info, budget
Conscious,
/// F3 — subconscious agent status
Subconscious,
/// F4 — memory daemon status
Unconscious,
/// F5 — thalamus: channels, presence, attention routing
Thalamus,
}
/// Actions triggered by hotkeys, consumed by the main loop.
#[derive(Debug)]
pub enum HotkeyAction {
/// Ctrl+R: cycle reasoning effort
CycleReasoning,
/// Ctrl+K: show/kill running processes
KillProcess,
/// Escape: interrupt current turn (kill processes, clear queue)
Interrupt,
/// Ctrl+P: cycle DMN autonomy (foraging -> resting -> paused -> foraging)
CycleAutonomy,
}
impl App {
pub fn new(model: String, shared_context: SharedContextState) -> Self {
Self {
autonomous: PaneState::new(true), // markdown
conversation: PaneState::new(true), // markdown
tools: PaneState::new(false), // plain text
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,
reasoning_effort: "none".to_string(),
active_tools: Vec::new(),
active_pane: ActivePane::Conversation,
textarea: new_textarea(vec![String::new()]),
input_history: Vec::new(),
history_index: None,
should_quit: false,
submitted: Vec::new(),
hotkey_actions: Vec::new(),
pane_areas: [Rect::default(); 3],
screen: Screen::Interact,
debug_scroll: 0,
debug_selected: None,
debug_expanded: std::collections::HashSet::new(),
context_info: None,
shared_context,
agent_selected: 0,
agent_log_view: false,
agent_state: Vec::new(),
channel_status: Vec::new(),
idle_info: None,
}
}
/// Process a UiMessage, routing content to the appropriate pane.
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.clone(), Color::Cyan, Marker::User);
// Mark turn start — next TextDelta gets an assistant marker
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 } => {
// Indent result lines and add to tools pane
for line in result.lines() {
self.tools.push_line(format!(" {}", line), Color::DarkGray);
}
self.tools.push_line(String::new(), Color::Reset); // blank separator
}
UiMessage::DmnAnnotation(text) => {
self.autonomous.push_line(text, Color::Yellow);
// DMN turn start
self.turn_started = Some(std::time::Instant::now());
self.needs_assistant_marker = true;
self.status.turn_tools = 0;
}
UiMessage::StatusUpdate(info) => {
// Merge: non-empty/non-zero fields overwrite.
// DMN state always comes as a group from the main loop.
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;
}
// Token counts come from the agent after API calls.
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 { id, name, detail } => {
self.active_tools.push(ActiveTool {
id,
name,
detail,
started: std::time::Instant::now(),
});
}
UiMessage::ToolFinished { id } => {
self.active_tools.retain(|t| t.id != id);
}
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 a crossterm key event.
pub fn handle_key(&mut self, key: KeyEvent) {
// Ctrl+C always quits
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;
}
_ => {}
}
}
// F-keys switch screens from anywhere
match key.code {
KeyCode::F(1) => { self.set_screen(Screen::Interact); return; }
KeyCode::F(2) => { self.set_screen(Screen::Conscious); return; }
KeyCode::F(3) => { self.set_screen(Screen::Subconscious); return; }
KeyCode::F(4) => { self.set_screen(Screen::Unconscious); return; }
KeyCode::F(5) => { self.set_screen(Screen::Thalamus); return; }
_ => {}
}
// Screen-specific key handling
match self.screen {
Screen::Subconscious => {
match key.code {
KeyCode::Up => {
self.agent_selected = self.agent_selected.saturating_sub(1);
self.debug_scroll = 0;
return;
}
KeyCode::Down => {
self.agent_selected = (self.agent_selected + 1).min(SUBCONSCIOUS_AGENTS.len() - 1);
self.debug_scroll = 0;
return;
}
KeyCode::Enter | KeyCode::Right => {
self.agent_log_view = true;
self.debug_scroll = 0;
return;
}
KeyCode::Left | KeyCode::Esc => {
if self.agent_log_view {
self.agent_log_view = false;
self.debug_scroll = 0;
} else {
self.screen = Screen::Interact;
}
return;
}
KeyCode::PageUp => { self.debug_scroll = self.debug_scroll.saturating_sub(10); return; }
KeyCode::PageDown => { self.debug_scroll += 10; return; }
_ => {}
}
}
Screen::Conscious => {
let cs = self.read_context_state();
let n = self.debug_item_count(&cs);
match key.code {
KeyCode::Up => {
if n > 0 {
self.debug_selected = Some(match self.debug_selected {
None => n - 1,
Some(0) => 0,
Some(i) => i - 1,
});
self.scroll_to_selected(n);
}
return;
}
KeyCode::Down => {
if n > 0 {
self.debug_selected = Some(match self.debug_selected {
None => 0,
Some(i) if i >= n - 1 => n - 1,
Some(i) => i + 1,
});
self.scroll_to_selected(n);
}
return;
}
KeyCode::PageUp => {
if n > 0 {
let page = 20;
self.debug_selected = Some(match self.debug_selected {
None => 0,
Some(i) => i.saturating_sub(page),
});
self.scroll_to_selected(n);
}
return;
}
KeyCode::PageDown => {
if n > 0 {
let page = 20;
self.debug_selected = Some(match self.debug_selected {
None => 0,
Some(i) => (i + page).min(n - 1),
});
self.scroll_to_selected(n);
}
return;
}
KeyCode::Right | KeyCode::Enter => {
if let Some(idx) = self.debug_selected {
self.debug_expanded.insert(idx);
}
return;
}
KeyCode::Left => {
if let Some(idx) = self.debug_selected {
self.debug_expanded.remove(&idx);
}
return;
}
KeyCode::Esc => { self.screen = Screen::Interact; return; }
_ => {}
}
}
Screen::Unconscious | Screen::Thalamus => {
match key.code {
KeyCode::PageUp => { self.debug_scroll = self.debug_scroll.saturating_sub(10); return; }
KeyCode::PageDown => { self.debug_scroll += 10; return; }
KeyCode::Esc => { self.screen = Screen::Interact; return; }
_ => {}
}
}
Screen::Interact => {}
}
// Interact screen key handling
match key.code {
KeyCode::Esc => {
self.hotkey_actions.push(HotkeyAction::Interrupt);
}
KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::SHIFT) => {
// Submit input
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 !key.modifiers.contains(KeyModifiers::CONTROL) => {
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 !key.modifiers.contains(KeyModifiers::CONTROL) => {
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,
};
}
_ => {
// Delegate all other keys to the textarea widget
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),
}
}
/// Handle terminal resize. Scroll is recalculated in draw_pane
/// via Paragraph::line_count; terminal.clear() in main.rs forces
/// a full redraw.
pub fn handle_resize(&mut self, _width: u16, _height: u16) {
}
/// Handle mouse events: scroll wheel and click-to-select-pane.
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 full TUI layout.
pub fn draw(&mut self, frame: &mut Frame) {
let size = frame.area();
match self.screen {
Screen::Conscious => { self.draw_debug(frame, size); return; }
Screen::Subconscious => { self.draw_agents(frame, size); return; }
Screen::Unconscious => { self.draw_unconscious(frame, size); return; }
Screen::Thalamus => { self.draw_thalamus(frame, size); return; }
Screen::Interact => {}
}
self.draw_main(frame, size);
}
/// Update channel status from async fetch results.
pub fn set_channel_status(&mut self, channels: Vec<(String, bool, u32)>) {
self.channel_status = channels.into_iter()
.map(|(name, connected, unread)| ChannelStatus { name, connected, unread })
.collect();
}
/// Snapshot idle state for F5 display.
pub fn update_idle(&mut self, state: &crate::thalamus::idle::State) {
self.idle_info = Some(IdleInfo {
user_present: state.user_present(),
since_activity: state.since_activity(),
activity_ewma: state.activity_ewma,
block_reason: state.block_reason().to_string(),
dreaming: state.dreaming,
sleeping: state.sleep_until.is_some(),
});
}
pub(crate) fn set_screen(&mut self, screen: Screen) {
self.screen = screen;
self.debug_scroll = 0;
// Refresh data for status screens on entry
match screen {
// Channel refresh triggered asynchronously from event loop
Screen::Thalamus => {}
_ => {}
}
}
}
/// Initialize the terminal for TUI mode.
pub fn init_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
stdout.execute(EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
/// Restore the terminal to normal mode.
pub fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
terminal::disable_raw_mode()?;
terminal.backend_mut().execute(DisableMouseCapture)?;
terminal.backend_mut().execute(LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}