diff --git a/Cargo.toml b/Cargo.toml index d8411b8..57986ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,5 +89,5 @@ name = "find-deleted" path = "src/bin/find-deleted.rs" [[bin]] -name = "poc-agent" -path = "src/bin/poc-agent.rs" +name = "consciousness" +path = "src/bin/consciousness.rs" diff --git a/src/agent/tui.rs b/src/agent/tui.rs deleted file mode 100644 index c93bb86..0000000 --- a/src/agent/tui.rs +++ /dev/null @@ -1,1400 +0,0 @@ -// tui.rs — 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. - -const SCREEN_LEGEND: &str = " F1=main F2=agents F10=context "; -const AGENT_NAMES: &[&str] = &["surface-observe", "journal", "reflect", "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::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, - Frame, Terminal, -}; -use std::io; - -use crate::agent::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. -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 `[]`). -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)] -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)] -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. -struct PaneState { - lines: Vec>, - /// Turn markers — parallel to lines, same length. - markers: Vec, - /// Current line being built (no trailing newline yet) — plain mode only. - current_line: String, - /// Color applied to streaming text (set before append_text) — plain mode only. - current_color: Color, - /// Raw markdown text of the current streaming response. - md_buffer: String, - /// Whether this pane parses streaming text as markdown. - use_markdown: bool, - /// Marker to apply to the next line pushed (for turn start tracking). - pending_marker: Marker, - /// Scroll offset in visual (wrapped) lines from the top. - scroll: u16, - /// Whether the user has scrolled away from the bottom. - pinned: bool, - /// Last known total visual lines (set during draw by Paragraph::line_count). - last_total_lines: u16, - /// Last known inner height (set during draw). - 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). - 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(). - fn all_lines(&self) -> Vec> { - 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. - fn all_lines_with_markers(&self) -> (Vec>, Vec) { - let mut lines: Vec> = self.lines.clone(); - let mut markers: Vec = 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). -fn new_textarea(lines: Vec) -> 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. -fn parse_markdown(md: &str) -> Vec> { - tui_markdown::from_str(md) - .lines - .into_iter() - .map(|line| { - let spans: Vec> = 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. -struct ActiveTool { - id: String, - name: String, - detail: String, - started: std::time::Instant, -} - -/// Main TUI application state. -pub struct App { - autonomous: PaneState, - conversation: PaneState, - tools: PaneState, - status: StatusInfo, - /// Live activity indicator ("thinking...", "calling: bash", etc). - activity: String, - /// When the current turn started (for elapsed timer). - turn_started: Option, - /// When the current LLM call started (for per-call timer). - call_started: Option, - /// Stream timeout for the current call (for display). - call_timeout_secs: u64, - /// Whether to emit a ● marker before the next assistant TextDelta. - 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, - active_tools: Vec, - 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, - history_index: Option, - /// Whether to quit. - pub should_quit: bool, - /// Submitted input lines waiting to be consumed. - pub submitted: Vec, - /// Pending hotkey actions for the main loop to process. - pub hotkey_actions: Vec, - /// Pane areas from last draw (for mouse click → pane selection). - pane_areas: [Rect; 3], // [autonomous, conversation, tools] - /// Active overlay screen (F1=context, F2=agents, Ctrl+D=context). - overlay: Option, - /// Debug screen scroll offset. - debug_scroll: u16, - /// Index of selected context section in debug view (for expand/collapse). - debug_selected: Option, - /// Which context section indices are expanded. - debug_expanded: std::collections::HashSet, - /// Context loading info for the debug screen. - context_info: Option, - /// Live context state — shared with agent, read directly for debug screen. - shared_context: SharedContextState, - /// Agent screen: selected agent index. - agent_selected: usize, - /// Agent screen: viewing log for selected agent. - agent_log_view: bool, - /// Agent state from last cycle update. - agent_state: Vec, -} - -/// Overlay screens toggled by F-keys. -#[derive(Debug, Clone, Copy, PartialEq)] -enum Overlay { - /// F1 / Ctrl+D — context window, model info, budget - Context, - /// F2 — subconscious agent status - Agents, -} - -/// 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], - overlay: None, - 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(), - } - } - - /// Process a UiMessage, routing content to the appropriate pane. - pub fn handle_ui_message(&mut self, msg: UiMessage) { - use crate::agent::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('d') => { - // Legacy alias for F10 - self.set_overlay(Overlay::Context); - return; - } - KeyCode::Char('p') => { - self.hotkey_actions.push(HotkeyAction::CycleAutonomy); - return; - } - _ => {} - } - } - - // Overlay screen captures scroll keys and Esc - if self.overlay.is_some() { - match key.code { - KeyCode::F(1) => { - self.overlay = None; - return; - } - KeyCode::F(10) => { self.set_overlay(Overlay::Context); return; } - KeyCode::F(2) => { self.set_overlay(Overlay::Agents); return; } - KeyCode::PageUp => { self.debug_scroll = self.debug_scroll.saturating_sub(10); return; } - KeyCode::PageDown => { self.debug_scroll += 10; return; } - _ => {} - } - - // Screen-specific key handling - match self.overlay { - Some(Overlay::Agents) => { - 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(AGENT_NAMES.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.overlay = None; - } - return; - } - _ => {} - } - } - Some(Overlay::Context) => { - match key.code { - KeyCode::Up => { - let cs = self.read_context_state(); - let n = self.debug_item_count(&cs); - if n > 0 { - self.debug_selected = Some(match self.debug_selected { - None => n - 1, - Some(0) => 0, - Some(i) => i - 1, - }); - } - return; - } - KeyCode::Down => { - let cs = self.read_context_state(); - let n = self.debug_item_count(&cs); - 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, - }); - } - 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; - } - _ => {} - } - } - None => {} - } - } - - match key.code { - KeyCode::F(10) => { self.set_overlay(Overlay::Context); return; } - KeyCode::F(2) => { self.set_overlay(Overlay::Agents); return; } - 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.overlay { - Some(Overlay::Context) => { self.draw_debug(frame, size); return; } - Some(Overlay::Agents) => { self.draw_agents(frame, size); return; } - None => {} - } - - // Main layout: content area + active tools overlay + status bar - let tool_lines = self.active_tools.len() as u16; - 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]); - - // Draw active tools overlay - if !self.active_tools.is_empty() { - let tool_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::DIM); - let tool_text: Vec = self.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 !self.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) - } else { - String::new() - }; - let activity_part = if self.activity.is_empty() { - String::new() - } else { - format!(" | {}{}{}", self.activity, tools_info, timer) - }; - - let budget_part = if self.status.context_budget.is_empty() { - String::new() - } else { - format!(" [{}]", self.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, - budget_part, - activity_part, - ); - - let proc_indicator = if self.running_processes > 0 { - format!(" {}proc", self.running_processes) - } else { - String::new() - }; - let reason_indicator = if self.reasoning_effort != "none" { - format!(" reason:{}", self.reasoning_effort) - } else { - String::new() - }; - let right_legend = format!( - "{}{} ^P:pause ^R:reason ^K:kill | {} ", - reason_indicator, - proc_indicator, - self.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); - } - - /// Read the live context state from the shared lock. - fn read_context_state(&self) -> Vec { - self.shared_context.read().map_or_else(|_| Vec::new(), |s| s.clone()) - } - - /// Draw the debug screen — full-screen overlay with context and runtime info. - /// Count total selectable items in the context state tree. - fn debug_item_count(&self, context_state: &[crate::agent::ui_channel::ContextSection]) -> usize { - fn count_section(section: &crate::agent::ui_channel::ContextSection, expanded: &std::collections::HashSet, idx: &mut usize) -> usize { - let my_idx = *idx; - *idx += 1; - let mut total = 1; - if expanded.contains(&my_idx) { - for child in §ion.children { - total += count_section(child, expanded, idx); - } - } - total - } - let mut idx = 0; - let mut total = 0; - for section in context_state { - total += count_section(section, &self.debug_expanded, &mut idx); - } - total - } - - /// Render a context section as a tree node with optional children. - fn render_debug_section( - &self, - section: &crate::agent::ui_channel::ContextSection, - depth: usize, - start_idx: usize, - lines: &mut Vec, - idx: &mut usize, - ) { - let my_idx = *idx; - let selected = self.debug_selected == Some(my_idx); - let expanded = self.debug_expanded.contains(&my_idx); - let has_children = !section.children.is_empty(); - let has_content = !section.content.is_empty(); - let expandable = has_children || has_content; - - let indent = " ".repeat(depth + 1); - let marker = if !expandable { - " " - } else if expanded { - "▼" - } else { - "▶" - }; - let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, section.tokens); - let style = if selected { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - lines.push(Line::styled(label, style)); - *idx += 1; - - if expanded { - if has_children { - for child in §ion.children { - self.render_debug_section(child, depth + 1, start_idx, lines, idx); - } - } else if has_content { - let content_indent = format!("{} │ ", " ".repeat(depth + 1)); - let content_lines: Vec<&str> = section.content.lines().collect(); - let show = content_lines.len().min(50); - for line in &content_lines[..show] { - lines.push(Line::styled( - format!("{}{}", content_indent, line), - Style::default().fg(Color::DarkGray), - )); - } - if content_lines.len() > 50 { - lines.push(Line::styled( - format!("{}... ({} more lines)", content_indent, content_lines.len() - 50), - Style::default().fg(Color::DarkGray), - )); - } - } - } - } - - fn set_overlay(&mut self, screen: Overlay) { - self.overlay = Some(screen); - self.debug_scroll = 0; - } - - fn draw_agents(&self, frame: &mut Frame, size: Rect) { - let output_dir = crate::store::memory_dir().join("agent-output"); - - if self.agent_log_view { - self.draw_agent_log(frame, size, &output_dir); - return; - } - - let mut lines: Vec = Vec::new(); - let section = Style::default().fg(Color::Yellow); - let _dim = Style::default().fg(Color::DarkGray); - let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); - - lines.push(Line::raw("")); - lines.push(Line::styled("── Subconscious Agents ──", section)); - lines.push(Line::styled(" (↑/↓ select, Enter/→ view log, Esc back)", hint)); - lines.push(Line::raw("")); - - for (i, &name) in AGENT_NAMES.iter().enumerate() { - let selected = i == self.agent_selected; - let prefix = if selected { "▸ " } else { " " }; - let bg = if selected { Style::default().bg(Color::DarkGray) } else { Style::default() }; - - let agent = self.agent_state.iter().find(|a| a.name == name); - - match agent.and_then(|a| a.pid) { - Some(pid) => { - let phase = agent.and_then(|a| a.phase.as_deref()).unwrap_or("?"); - lines.push(Line::from(vec![ - Span::styled(format!("{}{:<20}", prefix, name), bg.fg(Color::Green)), - Span::styled("● ", bg.fg(Color::Green)), - Span::styled(format!("pid {} phase: {}", pid, phase), bg), - ])); - } - None => { - lines.push(Line::from(vec![ - Span::styled(format!("{}{:<20}", prefix, name), bg.fg(Color::Gray)), - Span::styled("○ idle", bg.fg(Color::DarkGray)), - ])); - } - } - } - - let block = Block::default() - .title_top(Line::from(SCREEN_LEGEND).left_aligned()) - .title_top(Line::from(" agents ").right_aligned()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); - - let para = Paragraph::new(lines) - .block(block) - .scroll((self.debug_scroll, 0)); - frame.render_widget(para, size); - } - - fn draw_agent_log(&self, frame: &mut Frame, size: Rect, _output_dir: &std::path::Path) { - let name = AGENT_NAMES.get(self.agent_selected).unwrap_or(&"?"); - let agent = self.agent_state.iter().find(|a| a.name == *name); - let mut lines: Vec = Vec::new(); - let section = Style::default().fg(Color::Yellow); - let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); - - lines.push(Line::raw("")); - lines.push(Line::styled(format!("── {} ──", name), section)); - lines.push(Line::styled(" (Esc/← back, PgUp/PgDn scroll)", hint)); - lines.push(Line::raw("")); - - // Show pid status from state - match agent.and_then(|a| a.pid) { - Some(pid) => { - let phase = agent.and_then(|a| a.phase.as_deref()).unwrap_or("?"); - lines.push(Line::from(vec![ - Span::styled(" Status: ", Style::default()), - Span::styled(format!("● running pid {} phase: {}", pid, phase), - Style::default().fg(Color::Green)), - ])); - } - None => { - lines.push(Line::styled(" Status: idle", Style::default().fg(Color::DarkGray))); - } - } - - // Show log path - if let Some(log_path) = agent.and_then(|a| a.log_path.as_ref()) { - lines.push(Line::raw(format!(" Log: {}", log_path.display()))); - } - lines.push(Line::raw("")); - - // Show agent log tail - lines.push(Line::styled("── Agent Log ──", section)); - if let Some(content) = agent - .and_then(|a| a.log_path.as_ref()) - .and_then(|p| std::fs::read_to_string(p).ok()) - { - let log_lines: Vec<&str> = content.lines().collect(); - let start = log_lines.len().saturating_sub(40); - for line in &log_lines[start..] { - lines.push(Line::raw(format!(" {}", line))); - } - } else { - lines.push(Line::styled(" (no log available)", hint)); - } - - let block = Block::default() - .title_top(Line::from(SCREEN_LEGEND).left_aligned()) - .title_top(Line::from(format!(" {} ", name)).right_aligned()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); - - let para = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }) - .scroll((self.debug_scroll, 0)); - frame.render_widget(para, size); - } - - fn draw_debug(&self, frame: &mut Frame, size: Rect) { - let mut lines: Vec = Vec::new(); - let section = Style::default().fg(Color::Yellow); - - // Model - lines.push(Line::styled("── Model ──", section)); - let model_display = self.context_info.as_ref() - .map_or_else(|| self.status.model.clone(), |i| i.model.clone()); - lines.push(Line::raw(format!(" Current: {}", model_display))); - if let Some(ref info) = self.context_info { - lines.push(Line::raw(format!(" Backend: {}", info.backend))); - lines.push(Line::raw(format!(" Prompt: {}", info.prompt_file))); - lines.push(Line::raw(format!(" Available: {}", info.available_models.join(", ")))); - } - lines.push(Line::raw("")); - - // Context state - lines.push(Line::styled("── Context State ──", section)); - lines.push(Line::raw(format!(" Prompt tokens: {}K", self.status.prompt_tokens / 1000))); - if !self.status.context_budget.is_empty() { - lines.push(Line::raw(format!(" Budget: {}", self.status.context_budget))); - } - let context_state = self.read_context_state(); - if !context_state.is_empty() { - let total: usize = context_state.iter().map(|s| s.tokens).sum(); - lines.push(Line::raw("")); - lines.push(Line::styled( - " (↑/↓ select, →/Enter expand, ← collapse, PgUp/PgDn scroll)", - Style::default().fg(Color::DarkGray), - )); - lines.push(Line::raw("")); - - // Flatten tree into indexed entries for selection - let mut flat_idx = 0usize; - for section in &context_state { - self.render_debug_section(section, 0, flat_idx, &mut lines, &mut flat_idx); - } - - lines.push(Line::raw(format!(" {:23} {:>6} tokens", "────────", "──────"))); - lines.push(Line::raw(format!(" {:23} {:>6} tokens", "Total", total))); - } else if let Some(ref info) = self.context_info { - lines.push(Line::raw(format!(" System prompt: {:>6} chars", info.system_prompt_chars))); - lines.push(Line::raw(format!(" Context message: {:>6} chars", info.context_message_chars))); - } - lines.push(Line::raw("")); - - // Runtime - lines.push(Line::styled("── Runtime ──", section)); - lines.push(Line::raw(format!( - " DMN: {} ({}/{})", - self.status.dmn_state, self.status.dmn_turns, self.status.dmn_max_turns, - ))); - lines.push(Line::raw(format!(" Reasoning: {}", self.reasoning_effort))); - lines.push(Line::raw(format!(" Running processes: {}", self.running_processes))); - lines.push(Line::raw(format!(" Active tools: {}", self.active_tools.len()))); - - let block = Block::default() - .title_top(Line::from(SCREEN_LEGEND).left_aligned()) - .title_top(Line::from(" context ").right_aligned()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); - - let para = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }) - .scroll((self.debug_scroll, 0)); - - frame.render_widget(para, size); - } -} - -/// Draw the conversation pane with a two-column layout: marker gutter + text. -/// The gutter shows ● at turn boundaries, aligned with the input gutter. -fn draw_conversation_pane( - frame: &mut Frame, - area: Rect, - pane: &mut PaneState, - is_active: bool, -) { - 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; - } - - // Split inner area into gutter (2 chars) + text - let cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(2), - Constraint::Min(1), - ]) - .split(inner); - - let gutter_area = cols[0]; - let text_area = cols[1]; - - // Get lines and markers - let (lines, markers) = pane.all_lines_with_markers(); - let text_width = text_area.width; - - // Compute visual row for each logical line (accounting for word wrap) - let mut visual_rows: Vec = Vec::with_capacity(lines.len()); - let mut cumulative: u16 = 0; - for line in &lines { - visual_rows.push(cumulative); - let para = Paragraph::new(line.clone()).wrap(Wrap { trim: false }); - let height = para.line_count(text_width) as u16; - cumulative += height.max(1); - } - let total_visual = cumulative; - - pane.last_total_lines = total_visual; - pane.last_height = inner.height; - - if !pane.pinned { - pane.scroll = total_visual.saturating_sub(inner.height); - } - - // Render text column - let text_para = Paragraph::new(lines.clone()) - .wrap(Wrap { trim: false }) - .scroll((pane.scroll, 0)); - frame.render_widget(text_para, text_area); - - // Render gutter markers at the correct visual rows - let mut gutter_lines: Vec> = Vec::new(); - let mut next_visual = 0u16; - for (i, &marker) in markers.iter().enumerate() { - let row = visual_rows[i]; - // Fill blank lines up to this marker's row - while next_visual < row { - gutter_lines.push(Line::raw("")); - next_visual += 1; - } - let marker_text = match marker { - Marker::User => Line::styled("● ", Style::default().fg(Color::Cyan)), - Marker::Assistant => Line::styled("● ", Style::default().fg(Color::Magenta)), - Marker::None => Line::raw(""), - }; - gutter_lines.push(marker_text); - next_visual = row + 1; - - // Fill remaining visual lines for this logical line (wrap continuation) - let para = Paragraph::new(lines[i].clone()).wrap(Wrap { trim: false }); - let height = para.line_count(text_width) as u16; - for _ in 1..height.max(1) { - gutter_lines.push(Line::raw("")); - next_visual += 1; - } - } - - let gutter_para = Paragraph::new(gutter_lines) - .scroll((pane.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>, -) { - let inner_height = area.height.saturating_sub(2); - - 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 lines = pane.all_lines(); - let paragraph = Paragraph::new(lines) - .block(block.clone()) - .wrap(Wrap { trim: false }); - - // Let ratatui tell us the total visual lines — no homegrown wrapping math. - let total = paragraph.line_count(area.width.saturating_sub(2)) as u16; - pane.last_total_lines = total; - pane.last_height = inner_height; - - if !pane.pinned { - pane.scroll = total.saturating_sub(inner_height); - } - - let paragraph = paragraph.scroll((pane.scroll, 0)); - frame.render_widget(paragraph, area); -} - -/// Initialize the terminal for TUI mode. -pub fn init_terminal() -> io::Result>> { - 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>) -> io::Result<()> { - terminal::disable_raw_mode()?; - terminal.backend_mut().execute(DisableMouseCapture)?; - terminal.backend_mut().execute(LeaveAlternateScreen)?; - terminal.show_cursor()?; - Ok(()) -} diff --git a/src/bin/poc-agent.rs b/src/bin/consciousness.rs similarity index 98% rename from src/bin/poc-agent.rs rename to src/bin/consciousness.rs index deb96f3..e3eabf5 100644 --- a/src/bin/poc-agent.rs +++ b/src/bin/consciousness.rs @@ -31,12 +31,12 @@ use tokio::sync::{mpsc, Mutex}; use clap::Parser; use poc_memory::dbglog; -use poc_memory::agent::*; -use poc_memory::agent::runner::{Agent, TurnResult}; -use poc_memory::agent::api::ApiClient; -use poc_memory::agent::tui::HotkeyAction; +use poc_memory::user::*; +use poc_memory::user::runner::{Agent, TurnResult}; +use poc_memory::user::api::ApiClient; +use poc_memory::user::tui::HotkeyAction; use poc_memory::config::{self, AppConfig, SessionConfig}; -use poc_memory::agent::ui_channel::{ContextInfo, StatusInfo, StreamTarget, UiMessage}; +use poc_memory::user::ui_channel::{ContextInfo, StatusInfo, StreamTarget, UiMessage}; /// Compaction threshold — context is rebuilt when prompt tokens exceed this. fn compaction_threshold(app: &AppConfig) -> u32 { @@ -45,13 +45,6 @@ fn compaction_threshold(app: &AppConfig) -> u32 { #[tokio::main] async fn main() { - let console_sock = dirs::home_dir() - .unwrap_or_default() - .join(".consciousness/agent-sessions/console.sock"); - let _ = std::fs::remove_file(&console_sock); - console_subscriber::ConsoleLayer::builder() - .server_addr(console_sock.as_path()) - .init(); let cli = cli::CliArgs::parse(); // Subcommands that don't launch the TUI @@ -66,7 +59,7 @@ async fn main() { Some(cli::SubCmd::Write { message }) => { let msg = message.join(" "); if msg.is_empty() { - eprintln!("Usage: poc-agent write "); + eprintln!("Usage: consciousness write "); std::process::exit(1); } if let Err(e) = observe::cmd_write(&msg, cli.debug).await { @@ -349,7 +342,7 @@ impl Session { async fn handle_command(&mut self, input: &str) -> Command { // Declarative command table — /help reads from this. const COMMANDS: &[(&str, &str)] = &[ - ("/quit", "Exit poc-agent"), + ("/quit", "Exit consciousness"), ("/new", "Start fresh session (saves current)"), ("/save", "Save session to disk"), ("/retry", "Re-run last turn"), @@ -532,7 +525,7 @@ impl Session { let entries = agent_guard.entries_mut(); let mut last_user_text = None; while let Some(entry) = entries.last() { - if entry.message().role == poc_memory::agent::types::Role::User { + if entry.message().role == poc_memory::user::types::Role::User { last_user_text = Some(entries.pop().unwrap().message().content_text().to_string()); break; @@ -823,7 +816,7 @@ async fn run(cli: cli::CliArgs) -> Result<()> { let mut app = tui::App::new(config.model.clone(), shared_context.clone()); // Show startup info - let _ = ui_tx.send(UiMessage::Info("poc-agent v0.3 (tui)".into())); + let _ = ui_tx.send(UiMessage::Info("consciousness v0.3 (tui)".into())); let _ = ui_tx.send(UiMessage::Info(format!( " model: {} (available: {})", config.model, @@ -1101,7 +1094,7 @@ async fn run_tool_tests(ui_tx: &ui_channel::UiSender, tracker: &tools::ProcessTr /// assistant responses, and brief tool call summaries. Skips the system /// prompt, context message, DMN plumbing, and image injection messages. fn replay_session_to_ui(entries: &[types::ConversationEntry], ui_tx: &ui_channel::UiSender) { - use poc_memory::agent::ui_channel::StreamTarget; + use poc_memory::user::ui_channel::StreamTarget; dbglog!("[replay] replaying {} entries to UI", entries.len()); for (i, e) in entries.iter().enumerate() { diff --git a/src/config.rs b/src/config.rs index f1b637b..d29cc50 100644 --- a/src/config.rs +++ b/src/config.rs @@ -466,7 +466,7 @@ pub struct ResolvedModel { impl AppConfig { /// Resolve the active backend and assemble prompts into a SessionConfig. - pub fn resolve(&self, cli: &crate::agent::cli::CliArgs) -> Result { + pub fn resolve(&self, cli: &crate::user::cli::CliArgs) -> Result { let cwd = std::env::current_dir().context("Failed to get current directory")?; let (api_base, api_key, model, prompt_file); @@ -500,8 +500,8 @@ impl AppConfig { .with_context(|| format!("Failed to read {}", path.display()))?; (content, Vec::new(), 0, 0) } else { - let system_prompt = crate::agent::identity::assemble_system_prompt(); - let (context_parts, cc, mc) = crate::agent::identity::assemble_context_message(&cwd, &prompt_file, self.memory_project.as_deref(), &context_groups)?; + let system_prompt = crate::user::identity::assemble_system_prompt(); + let (context_parts, cc, mc) = crate::user::identity::assemble_context_message(&cwd, &prompt_file, self.memory_project.as_deref(), &context_groups)?; (system_prompt, context_parts, cc, mc) }; @@ -609,7 +609,7 @@ macro_rules! merge_opt { }; } -fn build_figment(cli: &crate::agent::cli::CliArgs) -> Figment { +fn build_figment(cli: &crate::user::cli::CliArgs) -> Figment { let mut f = Figment::from(Serialized::defaults(AppConfig::default())) .merge(Json5File(config_path())); @@ -628,14 +628,14 @@ fn build_figment(cli: &crate::agent::cli::CliArgs) -> Figment { } /// Load just the AppConfig — no validation, no prompt assembly. -pub fn load_app(cli: &crate::agent::cli::CliArgs) -> Result<(AppConfig, Figment)> { +pub fn load_app(cli: &crate::user::cli::CliArgs) -> Result<(AppConfig, Figment)> { let figment = build_figment(cli); let app: AppConfig = figment.extract().context("Failed to load configuration")?; Ok((app, figment)) } /// Load the full config: figment → AppConfig → resolve backend → assemble prompts. -pub fn load_session(cli: &crate::agent::cli::CliArgs) -> Result<(SessionConfig, Figment)> { +pub fn load_session(cli: &crate::user::cli::CliArgs) -> Result<(SessionConfig, Figment)> { let (app, figment) = load_app(cli)?; let config = app.resolve(cli)?; Ok((config, figment)) @@ -651,9 +651,9 @@ pub fn reload_for_model(app: &AppConfig, prompt_file: &str) -> Result<(String, V return Ok((content, Vec::new())); } - let system_prompt = crate::agent::identity::assemble_system_prompt(); + let system_prompt = crate::user::identity::assemble_system_prompt(); let context_groups = get().context_groups.clone(); - let (context_parts, _, _) = crate::agent::identity::assemble_context_message(&cwd, prompt_file, app.memory_project.as_deref(), &context_groups)?; + let (context_parts, _, _) = crate::user::identity::assemble_context_message(&cwd, prompt_file, app.memory_project.as_deref(), &context_groups)?; Ok((system_prompt, context_parts)) } diff --git a/src/lib.rs b/src/lib.rs index 8cedec5..dbe7fdb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,7 @@ // thought/ — shared cognitive substrate (tools, context, memory ops) // hippocampus/ — memory storage, retrieval, consolidation // subconscious/ — autonomous agents (reflect, surface, consolidate, ...) -// agent/ — interactive agent (TUI, tools, API clients) +// user/ — interactive agent (TUI, tools, API clients) /// Debug logging macro — writes to ~/.consciousness/logs/debug.log #[macro_export] @@ -24,7 +24,7 @@ macro_rules! dbglog { } // Agent infrastructure -pub mod agent; +pub mod user; // Shared cognitive infrastructure — used by both agent and subconscious pub mod thought; diff --git a/src/subconscious/api.rs b/src/subconscious/api.rs index 888bb51..07f5969 100644 --- a/src/subconscious/api.rs +++ b/src/subconscious/api.rs @@ -7,8 +7,8 @@ // // Activated when config has api_base_url set. -use crate::agent::api::ApiClient; -use crate::agent::types::*; +use crate::user::api::ApiClient; +use crate::user::types::*; use crate::thought::{self, ProcessTracker}; use std::sync::OnceLock; @@ -43,7 +43,7 @@ pub async fn call_api_with_tools( let client = get_client()?; // Set up a UI channel — we drain reasoning tokens into the log - let (ui_tx, mut ui_rx) = crate::agent::ui_channel::channel(); + let (ui_tx, mut ui_rx) = crate::user::ui_channel::channel(); // All available native tools for subconscious agents let all_tools = thought::memory_and_journal_definitions(); @@ -127,7 +127,7 @@ pub async fn call_api_with_tools( { let mut reasoning_buf = String::new(); while let Ok(ui_msg) = ui_rx.try_recv() { - if let crate::agent::ui_channel::UiMessage::Reasoning(r) = ui_msg { + if let crate::user::ui_channel::UiMessage::Reasoning(r) = ui_msg { reasoning_buf.push_str(&r); } } diff --git a/src/thought/context.rs b/src/thought/context.rs index 3146cd3..4240029 100644 --- a/src/thought/context.rs +++ b/src/thought/context.rs @@ -4,7 +4,7 @@ // Journal entries are loaded from the memory graph store, not from // a flat file — the parse functions are gone. -use crate::agent::types::*; +use crate::user::types::*; use chrono::{DateTime, Utc}; use tiktoken_rs::CoreBPE; diff --git a/src/thought/mod.rs b/src/thought/mod.rs index 7a2e722..bc50ecf 100644 --- a/src/thought/mod.rs +++ b/src/thought/mod.rs @@ -22,7 +22,7 @@ pub use bash::ProcessTracker; // Re-export ToolDef from agent::types for convenience — // tools define their schemas using this type. -pub use crate::agent::types::ToolDef; +pub use crate::user::types::ToolDef; /// Result of dispatching a tool call. pub struct ToolOutput { diff --git a/src/thought/training.rs b/src/thought/training.rs index a7d9a63..6f2b39b 100644 --- a/src/thought/training.rs +++ b/src/thought/training.rs @@ -8,9 +8,9 @@ // Column sums = response memory-dependence (training candidates) use std::time::Instant; -use crate::agent::api::ApiClient; -use crate::agent::types::*; -use crate::agent::ui_channel::{UiMessage, UiSender}; +use crate::user::api::ApiClient; +use crate::user::types::*; +use crate::user::ui_channel::{UiMessage, UiSender}; /// Timeout for individual /v1/score API calls. const SCORE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); @@ -53,6 +53,10 @@ pub async fn score_memories( client: &ApiClient, ui_tx: &UiSender, ) -> anyhow::Result { + let _ = ui_tx.send(UiMessage::Debug(format!( + "[training] in score_memories" + ))); + let memories: Vec<(usize, String)> = context.entries.iter().enumerate() .filter_map(|(i, e)| match e { ConversationEntry::Memory { key, .. } => Some((i, key.clone())), @@ -97,6 +101,7 @@ pub async fn score_memories( ))); // Baseline: score with all memories present + let _ = ui_tx.send(UiMessage::Debug("[training] serializing payload...".into())); let payload_size = serde_json::to_string(&all_messages) .map(|s| s.len()).unwrap_or(0); let _ = ui_tx.send(UiMessage::Debug(format!( @@ -128,12 +133,13 @@ pub async fn score_memories( match without { Ok(without) => { let elapsed = start.elapsed().as_secs_f64(); - // Match scores by message index and compute divergence + // Match scores by position (nth scored response), + // not message_index — indices shift when a memory + // is removed from the conversation. let mut row = Vec::new(); - for base_score in &baseline { + for (i, base_score) in baseline.iter().enumerate() { let base_lp = base_score.total_logprob; - let without_lp = without.iter() - .find(|s| s.message_index == base_score.message_index) + let without_lp = without.get(i) .map(|s| s.total_logprob) .unwrap_or(base_lp); let divergence = (base_lp - without_lp).max(0.0); diff --git a/src/agent/api/mod.rs b/src/user/api/mod.rs similarity index 98% rename from src/agent/api/mod.rs rename to src/user/api/mod.rs index b199bd2..694ad9c 100644 --- a/src/agent/api/mod.rs +++ b/src/user/api/mod.rs @@ -14,8 +14,8 @@ use std::time::{Duration, Instant}; use tokio::sync::mpsc; -use crate::agent::types::*; -use crate::agent::ui_channel::{UiMessage, UiSender}; +use crate::user::types::*; +use crate::user::ui_channel::{UiMessage, UiSender}; /// A JoinHandle that aborts its task when dropped. pub struct AbortOnDrop(tokio::task::JoinHandle<()>); @@ -470,9 +470,9 @@ pub fn build_response_message( } // Check for leaked tool calls in content text. - let leaked = crate::agent::parsing::parse_leaked_tool_calls(&content); + let leaked = crate::user::parsing::parse_leaked_tool_calls(&content); if !leaked.is_empty() { - let cleaned = crate::agent::parsing::strip_leaked_artifacts(&content); + let cleaned = crate::user::parsing::strip_leaked_artifacts(&content); return Message { role: Role::Assistant, content: if cleaned.trim().is_empty() { None } diff --git a/src/agent/api/openai.rs b/src/user/api/openai.rs similarity index 98% rename from src/agent/api/openai.rs rename to src/user/api/openai.rs index 4d263e8..4c73a7d 100644 --- a/src/agent/api/openai.rs +++ b/src/user/api/openai.rs @@ -8,8 +8,8 @@ use anyhow::Result; use reqwest::Client; use tokio::sync::mpsc; -use crate::agent::types::*; -use crate::agent::ui_channel::{UiMessage, UiSender}; +use crate::user::types::*; +use crate::user::ui_channel::{UiMessage, UiSender}; use super::StreamEvent; /// Stream SSE events from an OpenAI-compatible endpoint, sending diff --git a/src/agent/cli.rs b/src/user/cli.rs similarity index 95% rename from src/agent/cli.rs rename to src/user/cli.rs index 6925561..d973c71 100644 --- a/src/agent/cli.rs +++ b/src/user/cli.rs @@ -13,7 +13,7 @@ use clap::{Parser, Subcommand}; use std::path::PathBuf; #[derive(Parser, Debug)] -#[command(name = "poc-agent", about = "Substrate-independent AI agent")] +#[command(name = "consciousness", about = "Substrate-independent AI agent")] pub struct CliArgs { /// Select active backend ("anthropic" or "openrouter") #[arg(long)] diff --git a/src/agent/dmn.rs b/src/user/dmn.rs similarity index 100% rename from src/agent/dmn.rs rename to src/user/dmn.rs diff --git a/src/agent/identity.rs b/src/user/identity.rs similarity index 100% rename from src/agent/identity.rs rename to src/user/identity.rs diff --git a/src/agent/log.rs b/src/user/log.rs similarity index 98% rename from src/agent/log.rs rename to src/user/log.rs index 1a1052f..a2026ab 100644 --- a/src/agent/log.rs +++ b/src/user/log.rs @@ -14,7 +14,7 @@ use std::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; -use crate::agent::types::ConversationEntry; +use crate::user::types::ConversationEntry; pub struct ConversationLog { path: PathBuf, diff --git a/src/agent/mod.rs b/src/user/mod.rs similarity index 100% rename from src/agent/mod.rs rename to src/user/mod.rs diff --git a/src/agent/observe.rs b/src/user/observe.rs similarity index 97% rename from src/agent/observe.rs rename to src/user/observe.rs index 5fb7d92..ff4caf3 100644 --- a/src/agent/observe.rs +++ b/src/user/observe.rs @@ -15,7 +15,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{broadcast, Mutex}; -use crate::agent::ui_channel::UiMessage; +use crate::user::ui_channel::UiMessage; fn format_message(msg: &UiMessage) -> Option { match msg { @@ -99,7 +99,7 @@ pub async fn cmd_read_inner(follow: bool, block: bool, debug: bool) -> anyhow::R } let _ = std::fs::write(&cursor, len.to_string()); } else if !follow && !block { - println!("(no log yet — is poc-agent running?)"); + println!("(no log yet — is consciousness running?)"); return Ok(()); } @@ -111,7 +111,7 @@ pub async fn cmd_read_inner(follow: bool, block: bool, debug: bool) -> anyhow::R let sock = socket_path(); let stream = UnixStream::connect(&sock).await .map_err(|e| anyhow::anyhow!( - "can't connect for live streaming — is poc-agent running? ({})", e + "can't connect for live streaming — is consciousness running? ({})", e ))?; let (reader, _) = stream.into_split(); @@ -149,7 +149,7 @@ pub async fn cmd_write(message: &str, debug: bool) -> anyhow::Result<()> { } let stream = UnixStream::connect(&sock).await .map_err(|e| anyhow::anyhow!( - "can't connect — is poc-agent running? ({})", e + "can't connect — is consciousness running? ({})", e ))?; let (_, mut writer) = stream.into_split(); diff --git a/src/agent/parsing.rs b/src/user/parsing.rs similarity index 99% rename from src/agent/parsing.rs rename to src/user/parsing.rs index e2ea3b7..99ff9c7 100644 --- a/src/agent/parsing.rs +++ b/src/user/parsing.rs @@ -11,7 +11,7 @@ // Also handles streaming artifacts: whitespace inside XML tags from // token boundaries, tags, etc. -use crate::agent::types::*; +use crate::user::types::*; /// Parse leaked tool calls from response text. /// Looks for `...` blocks and tries both diff --git a/src/agent/runner.rs b/src/user/runner.rs similarity index 98% rename from src/agent/runner.rs rename to src/user/runner.rs index 9975d79..a5ff4be 100644 --- a/src/agent/runner.rs +++ b/src/user/runner.rs @@ -16,14 +16,14 @@ use anyhow::Result; use tiktoken_rs::CoreBPE; -use crate::agent::api::ApiClient; +use crate::user::api::ApiClient; use crate::thought::context as journal; -use crate::agent::log::ConversationLog; -use crate::agent::api::StreamEvent; -use crate::agent::tools; -use crate::agent::tools::ProcessTracker; -use crate::agent::types::*; -use crate::agent::ui_channel::{ContextSection, SharedContextState, StatusInfo, StreamTarget, UiMessage, UiSender}; +use crate::user::log::ConversationLog; +use crate::user::api::StreamEvent; +use crate::user::tools; +use crate::user::tools::ProcessTracker; +use crate::user::types::*; +use crate::user::ui_channel::{ContextSection, SharedContextState, StatusInfo, StreamTarget, UiMessage, UiSender}; /// Result of a single agent turn. pub struct TurnResult { @@ -113,7 +113,7 @@ impl Agent { working_stack: Vec::new(), entries: Vec::new(), }; - let session_id = format!("poc-agent-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S")); + let session_id = format!("consciousness-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S")); let agent_cycles = crate::subconscious::subconscious::AgentCycleState::new(&session_id); let mut agent = Self { client, @@ -375,7 +375,7 @@ impl Agent { let _ = ui_tx.send(UiMessage::TextDelta("\n".to_string(), target)); } - let msg = crate::agent::api::build_response_message(content, tool_calls); + let msg = crate::user::api::build_response_message(content, tool_calls); diff --git a/src/agent/tools/control.rs b/src/user/tools/control.rs similarity index 99% rename from src/agent/tools/control.rs rename to src/user/tools/control.rs index 01da464..6e3b4b9 100644 --- a/src/agent/tools/control.rs +++ b/src/user/tools/control.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result}; use super::ToolOutput; -use crate::agent::types::ToolDef; +use crate::user::types::ToolDef; pub(super) fn pause(_args: &serde_json::Value) -> Result { Ok(ToolOutput { diff --git a/src/agent/tools/mod.rs b/src/user/tools/mod.rs similarity index 98% rename from src/agent/tools/mod.rs rename to src/user/tools/mod.rs index e86231c..fea6487 100644 --- a/src/agent/tools/mod.rs +++ b/src/user/tools/mod.rs @@ -12,7 +12,7 @@ pub mod working_stack; pub use crate::thought::{ToolOutput, ProcessTracker, truncate_output}; pub use crate::thought::memory; -use crate::agent::types::ToolDef; +use crate::user::types::ToolDef; /// Dispatch a tool call by name. /// diff --git a/src/agent/tools/vision.rs b/src/user/tools/vision.rs similarity index 99% rename from src/agent/tools/vision.rs rename to src/user/tools/vision.rs index bc523d5..1f5d14f 100644 --- a/src/agent/tools/vision.rs +++ b/src/user/tools/vision.rs @@ -9,7 +9,7 @@ use base64::Engine; use serde::Deserialize; use super::ToolOutput; -use crate::agent::types::ToolDef; +use crate::user::types::ToolDef; #[derive(Deserialize)] struct Args { diff --git a/src/agent/tools/working_stack.rs b/src/user/tools/working_stack.rs similarity index 99% rename from src/agent/tools/working_stack.rs rename to src/user/tools/working_stack.rs index 1216884..abe9f39 100644 --- a/src/agent/tools/working_stack.rs +++ b/src/user/tools/working_stack.rs @@ -4,7 +4,7 @@ // internal tool — the agent uses it to maintain context across turns // and compaction. The model should never mention it to the user. -use crate::agent::types::ToolDef; +use crate::user::types::ToolDef; use serde_json::json; pub fn definition() -> ToolDef { diff --git a/src/user/tui/context_screen.rs b/src/user/tui/context_screen.rs new file mode 100644 index 0000000..6e0257b --- /dev/null +++ b/src/user/tui/context_screen.rs @@ -0,0 +1,186 @@ +// context_screen.rs — F2 context/debug overlay +// +// Full-screen overlay showing model info, context window breakdown, +// and runtime state. Supports tree navigation with expand/collapse. + +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::Line, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use super::{App, SCREEN_LEGEND}; + +impl App { + /// Read the live context state from the shared lock. + pub(crate) fn read_context_state(&self) -> Vec { + self.shared_context.read().map_or_else(|_| Vec::new(), |s| s.clone()) + } + + /// Count total selectable items in the context state tree. + pub(crate) fn debug_item_count(&self, context_state: &[crate::user::ui_channel::ContextSection]) -> usize { + fn count_section(section: &crate::user::ui_channel::ContextSection, expanded: &std::collections::HashSet, idx: &mut usize) -> usize { + let my_idx = *idx; + *idx += 1; + let mut total = 1; + if expanded.contains(&my_idx) { + for child in §ion.children { + total += count_section(child, expanded, idx); + } + } + total + } + let mut idx = 0; + let mut total = 0; + for section in context_state { + total += count_section(section, &self.debug_expanded, &mut idx); + } + total + } + + /// Keep the viewport scrolled so the selected item is visible. + /// Assumes ~1 line per item plus a header offset of ~8 lines. + pub(crate) fn scroll_to_selected(&mut self, _item_count: usize) { + let header_lines = 8u16; // model info + context state header + if let Some(sel) = self.debug_selected { + let sel_line = header_lines + sel as u16; + // Keep cursor within a comfortable range of the viewport + if sel_line < self.debug_scroll + 2 { + self.debug_scroll = sel_line.saturating_sub(2); + } else if sel_line > self.debug_scroll + 30 { + self.debug_scroll = sel_line.saturating_sub(15); + } + } + } + + /// Render a context section as a tree node with optional children. + pub(crate) fn render_debug_section( + &self, + section: &crate::user::ui_channel::ContextSection, + depth: usize, + start_idx: usize, + lines: &mut Vec, + idx: &mut usize, + ) { + let my_idx = *idx; + let selected = self.debug_selected == Some(my_idx); + let expanded = self.debug_expanded.contains(&my_idx); + let has_children = !section.children.is_empty(); + let has_content = !section.content.is_empty(); + let expandable = has_children || has_content; + + let indent = " ".repeat(depth + 1); + let marker = if !expandable { + " " + } else if expanded { + "▼" + } else { + "▶" + }; + let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, section.tokens); + let style = if selected { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + lines.push(Line::styled(label, style)); + *idx += 1; + + if expanded { + if has_children { + for child in §ion.children { + self.render_debug_section(child, depth + 1, start_idx, lines, idx); + } + } else if has_content { + let content_indent = format!("{} │ ", " ".repeat(depth + 1)); + let content_lines: Vec<&str> = section.content.lines().collect(); + let show = content_lines.len().min(50); + for line in &content_lines[..show] { + lines.push(Line::styled( + format!("{}{}", content_indent, line), + Style::default().fg(Color::DarkGray), + )); + } + if content_lines.len() > 50 { + lines.push(Line::styled( + format!("{}... ({} more lines)", content_indent, content_lines.len() - 50), + Style::default().fg(Color::DarkGray), + )); + } + } + } + } + + /// Draw the debug screen — full-screen overlay with context and runtime info. + pub(crate) fn draw_debug(&self, frame: &mut Frame, size: Rect) { + let mut lines: Vec = Vec::new(); + let section = Style::default().fg(Color::Yellow); + + // Model + lines.push(Line::styled("── Model ──", section)); + let model_display = self.context_info.as_ref() + .map_or_else(|| self.status.model.clone(), |i| i.model.clone()); + lines.push(Line::raw(format!(" Current: {}", model_display))); + if let Some(ref info) = self.context_info { + lines.push(Line::raw(format!(" Backend: {}", info.backend))); + lines.push(Line::raw(format!(" Prompt: {}", info.prompt_file))); + lines.push(Line::raw(format!(" Available: {}", info.available_models.join(", ")))); + } + lines.push(Line::raw("")); + + // Context state + lines.push(Line::styled("── Context State ──", section)); + lines.push(Line::raw(format!(" Prompt tokens: {}K", self.status.prompt_tokens / 1000))); + if !self.status.context_budget.is_empty() { + lines.push(Line::raw(format!(" Budget: {}", self.status.context_budget))); + } + let context_state = self.read_context_state(); + if !context_state.is_empty() { + let total: usize = context_state.iter().map(|s| s.tokens).sum(); + lines.push(Line::raw("")); + lines.push(Line::styled( + " (↑/↓ select, →/Enter expand, ← collapse, PgUp/PgDn scroll)", + Style::default().fg(Color::DarkGray), + )); + lines.push(Line::raw("")); + + // Flatten tree into indexed entries for selection + let mut flat_idx = 0usize; + for section in &context_state { + self.render_debug_section(section, 0, flat_idx, &mut lines, &mut flat_idx); + } + + lines.push(Line::raw(format!(" {:23} {:>6} tokens", "────────", "──────"))); + lines.push(Line::raw(format!(" {:23} {:>6} tokens", "Total", total))); + } else if let Some(ref info) = self.context_info { + lines.push(Line::raw(format!(" System prompt: {:>6} chars", info.system_prompt_chars))); + lines.push(Line::raw(format!(" Context message: {:>6} chars", info.context_message_chars))); + } + lines.push(Line::raw("")); + + // Runtime + lines.push(Line::styled("── Runtime ──", section)); + lines.push(Line::raw(format!( + " DMN: {} ({}/{})", + self.status.dmn_state, self.status.dmn_turns, self.status.dmn_max_turns, + ))); + lines.push(Line::raw(format!(" Reasoning: {}", self.reasoning_effort))); + lines.push(Line::raw(format!(" Running processes: {}", self.running_processes))); + lines.push(Line::raw(format!(" Active tools: {}", self.active_tools.len()))); + + let block = Block::default() + .title_top(Line::from(SCREEN_LEGEND).left_aligned()) + .title_top(Line::from(" context ").right_aligned()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let para = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((self.debug_scroll, 0)); + + frame.render_widget(para, size); + } +} diff --git a/src/user/tui/main_screen.rs b/src/user/tui/main_screen.rs new file mode 100644 index 0000000..2dfcb95 --- /dev/null +++ b/src/user/tui/main_screen.rs @@ -0,0 +1,341 @@ +// 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 super::{ActivePane, App, Marker, PaneState, SCREEN_LEGEND}; + +impl App { + /// Draw the main (F1) screen — four-pane layout with status bar. + pub(crate) fn draw_main(&mut self, frame: &mut Frame, size: Rect) { + // Main layout: content area + active tools overlay + status bar + let tool_lines = self.active_tools.len() as u16; + 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]); + + // Draw active tools overlay + if !self.active_tools.is_empty() { + let tool_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::DIM); + let tool_text: Vec = self.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 !self.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) + } else { + String::new() + }; + let activity_part = if self.activity.is_empty() { + String::new() + } else { + format!(" | {}{}{}", self.activity, tools_info, timer) + }; + + let budget_part = if self.status.context_budget.is_empty() { + String::new() + } else { + format!(" [{}]", self.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, + budget_part, + activity_part, + ); + + let proc_indicator = if self.running_processes > 0 { + format!(" {}proc", self.running_processes) + } else { + String::new() + }; + let reason_indicator = if self.reasoning_effort != "none" { + format!(" reason:{}", self.reasoning_effort) + } else { + String::new() + }; + let right_legend = format!( + "{}{} ^P:pause ^R:reason ^K:kill | {} ", + reason_indicator, + proc_indicator, + self.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); + } +} + +/// 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, +) { + 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; + } + + // Split inner area into gutter (2 chars) + text + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), + Constraint::Min(1), + ]) + .split(inner); + + let gutter_area = cols[0]; + let text_area = cols[1]; + + // Get lines and markers + let (lines, markers) = pane.all_lines_with_markers(); + let text_width = text_area.width; + + // Compute visual row for each logical line (accounting for word wrap) + let mut visual_rows: Vec = Vec::with_capacity(lines.len()); + let mut cumulative: u16 = 0; + for line in &lines { + visual_rows.push(cumulative); + let para = Paragraph::new(line.clone()).wrap(Wrap { trim: false }); + let height = para.line_count(text_width) as u16; + cumulative += height.max(1); + } + let total_visual = cumulative; + + pane.last_total_lines = total_visual; + pane.last_height = inner.height; + + if !pane.pinned { + pane.scroll = total_visual.saturating_sub(inner.height); + } + + // Render text column + let text_para = Paragraph::new(lines.clone()) + .wrap(Wrap { trim: false }) + .scroll((pane.scroll, 0)); + frame.render_widget(text_para, text_area); + + // Render gutter markers at the correct visual rows + let mut gutter_lines: Vec> = Vec::new(); + let mut next_visual = 0u16; + for (i, &marker) in markers.iter().enumerate() { + let row = visual_rows[i]; + // Fill blank lines up to this marker's row + while next_visual < row { + gutter_lines.push(Line::raw("")); + next_visual += 1; + } + let marker_text = match marker { + Marker::User => Line::styled("● ", Style::default().fg(Color::Cyan)), + Marker::Assistant => Line::styled("● ", Style::default().fg(Color::Magenta)), + Marker::None => Line::raw(""), + }; + gutter_lines.push(marker_text); + next_visual = row + 1; + + // Fill remaining visual lines for this logical line (wrap continuation) + let para = Paragraph::new(lines[i].clone()).wrap(Wrap { trim: false }); + let height = para.line_count(text_width) as u16; + for _ in 1..height.max(1) { + gutter_lines.push(Line::raw("")); + next_visual += 1; + } + } + + let gutter_para = Paragraph::new(gutter_lines) + .scroll((pane.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>, +) { + let inner_height = area.height.saturating_sub(2); + + 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 lines = pane.all_lines(); + let paragraph = Paragraph::new(lines) + .block(block.clone()) + .wrap(Wrap { trim: false }); + + // Let ratatui tell us the total visual lines — no homegrown wrapping math. + let total = paragraph.line_count(area.width.saturating_sub(2)) as u16; + pane.last_total_lines = total; + pane.last_height = inner_height; + + if !pane.pinned { + pane.scroll = total.saturating_sub(inner_height); + } + + let paragraph = paragraph.scroll((pane.scroll, 0)); + frame.render_widget(paragraph, area); +} diff --git a/src/user/tui/mod.rs b/src/user/tui/mod.rs new file mode 100644 index 0000000..5de8267 --- /dev/null +++ b/src/user/tui/mod.rs @@ -0,0 +1,848 @@ +// 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 "; +/// Subconscious agents — interact with conscious context +pub(crate) const SUBCONSCIOUS_AGENTS: &[&str] = &["surface-observe", "journal", "reflect"]; +/// Unconscious agents — background consolidation +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>, + /// Turn markers — parallel to lines, same length. + pub(crate) markers: Vec, + /// 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> { + 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>, Vec) { + let mut lines: Vec> = self.lines.clone(); + let mut markers: Vec = 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) -> 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> { + tui_markdown::from_str(md) + .lines + .into_iter() + .map(|line| { + let spans: Vec> = 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, + /// When the current LLM call started (for per-call timer). + pub(crate) call_started: Option, + /// 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, + 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, + history_index: Option, + /// Whether to quit. + pub should_quit: bool, + /// Submitted input lines waiting to be consumed. + pub submitted: Vec, + /// Pending hotkey actions for the main loop to process. + pub hotkey_actions: Vec, + /// Pane areas from last draw (for mouse click -> pane selection). + pub(crate) pane_areas: [Rect; 3], // [autonomous, conversation, tools] + /// Active screen (F1-F4). + pub(crate) 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, + /// Which context section indices are expanded. + pub(crate) debug_expanded: std::collections::HashSet, + /// Context loading info for the debug screen. + pub(crate) context_info: Option, + /// 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, +} + +/// Screens toggled by F-keys. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum Screen { + /// F1 — conversation + Interact, + /// F2 — context window, model info, budget + Conscious, + /// F3 — subconscious agent status + Subconscious, + /// F4 — memory daemon status + Unconscious, +} + +/// 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(), + } + } + + /// 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; } + _ => {} + } + + // 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 => { + 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::Interact => {} + } + + self.draw_main(frame, size); + } + + pub(crate) fn set_screen(&mut self, screen: Screen) { + self.screen = screen; + self.debug_scroll = 0; + } +} + +/// Initialize the terminal for TUI mode. +pub fn init_terminal() -> io::Result>> { + 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>) -> io::Result<()> { + terminal::disable_raw_mode()?; + terminal.backend_mut().execute(DisableMouseCapture)?; + terminal.backend_mut().execute(LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} diff --git a/src/user/tui/subconscious_screen.rs b/src/user/tui/subconscious_screen.rs new file mode 100644 index 0000000..21aa293 --- /dev/null +++ b/src/user/tui/subconscious_screen.rs @@ -0,0 +1,132 @@ +// subconscious_screen.rs — F3 subconscious agent overlay +// +// Shows agent list with status indicators, and a detail view +// with log tail for the selected agent. + +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use super::{App, SUBCONSCIOUS_AGENTS, SCREEN_LEGEND}; + +impl App { + pub(crate) fn draw_agents(&self, frame: &mut Frame, size: Rect) { + let output_dir = crate::store::memory_dir().join("agent-output"); + + if self.agent_log_view { + self.draw_agent_log(frame, size, &output_dir); + return; + } + + let mut lines: Vec = Vec::new(); + let section = Style::default().fg(Color::Yellow); + let _dim = Style::default().fg(Color::DarkGray); + let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); + + lines.push(Line::raw("")); + lines.push(Line::styled("── Subconscious Agents ──", section)); + lines.push(Line::styled(" (↑/↓ select, Enter/→ view log, Esc back)", hint)); + lines.push(Line::raw("")); + + for (i, &name) in SUBCONSCIOUS_AGENTS.iter().enumerate() { + let selected = i == self.agent_selected; + let prefix = if selected { "▸ " } else { " " }; + let bg = if selected { Style::default().bg(Color::DarkGray) } else { Style::default() }; + + let agent = self.agent_state.iter().find(|a| a.name == name); + + match agent.and_then(|a| a.pid) { + Some(pid) => { + let phase = agent.and_then(|a| a.phase.as_deref()).unwrap_or("?"); + lines.push(Line::from(vec![ + Span::styled(format!("{}{:<20}", prefix, name), bg.fg(Color::Green)), + Span::styled("● ", bg.fg(Color::Green)), + Span::styled(format!("pid {} phase: {}", pid, phase), bg), + ])); + } + None => { + lines.push(Line::from(vec![ + Span::styled(format!("{}{:<20}", prefix, name), bg.fg(Color::Gray)), + Span::styled("○ idle", bg.fg(Color::DarkGray)), + ])); + } + } + } + + let block = Block::default() + .title_top(Line::from(SCREEN_LEGEND).left_aligned()) + .title_top(Line::from(" subconscious ").right_aligned()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let para = Paragraph::new(lines) + .block(block) + .scroll((self.debug_scroll, 0)); + frame.render_widget(para, size); + } + + fn draw_agent_log(&self, frame: &mut Frame, size: Rect, _output_dir: &std::path::Path) { + let name = SUBCONSCIOUS_AGENTS.get(self.agent_selected).unwrap_or(&"?"); + let agent = self.agent_state.iter().find(|a| a.name == *name); + let mut lines: Vec = Vec::new(); + let section = Style::default().fg(Color::Yellow); + let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); + + lines.push(Line::raw("")); + lines.push(Line::styled(format!("── {} ──", name), section)); + lines.push(Line::styled(" (Esc/← back, PgUp/PgDn scroll)", hint)); + lines.push(Line::raw("")); + + // Show pid status from state + match agent.and_then(|a| a.pid) { + Some(pid) => { + let phase = agent.and_then(|a| a.phase.as_deref()).unwrap_or("?"); + lines.push(Line::from(vec![ + Span::styled(" Status: ", Style::default()), + Span::styled(format!("● running pid {} phase: {}", pid, phase), + Style::default().fg(Color::Green)), + ])); + } + None => { + lines.push(Line::styled(" Status: idle", Style::default().fg(Color::DarkGray))); + } + } + + // Show log path + if let Some(log_path) = agent.and_then(|a| a.log_path.as_ref()) { + lines.push(Line::raw(format!(" Log: {}", log_path.display()))); + } + lines.push(Line::raw("")); + + // Show agent log tail + lines.push(Line::styled("── Agent Log ──", section)); + if let Some(content) = agent + .and_then(|a| a.log_path.as_ref()) + .and_then(|p| std::fs::read_to_string(p).ok()) + { + let log_lines: Vec<&str> = content.lines().collect(); + let start = log_lines.len().saturating_sub(40); + for line in &log_lines[start..] { + lines.push(Line::raw(format!(" {}", line))); + } + } else { + lines.push(Line::styled(" (no log available)", hint)); + } + + let block = Block::default() + .title_top(Line::from(SCREEN_LEGEND).left_aligned()) + .title_top(Line::from(format!(" {} ", name)).right_aligned()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let para = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((self.debug_scroll, 0)); + frame.render_widget(para, size); + } +} diff --git a/src/user/tui/thalamus_screen.rs b/src/user/tui/thalamus_screen.rs new file mode 100644 index 0000000..31876f4 --- /dev/null +++ b/src/user/tui/thalamus_screen.rs @@ -0,0 +1,58 @@ +// thalamus_screen.rs — F5: attention routing / daemon status +// +// Shows poc-daemon status: presence detection, idle timer, +// notification routing, activity level. + +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::Line, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use super::{App, SCREEN_LEGEND}; + +fn fetch_daemon_status() -> Vec { + std::process::Command::new("poc-daemon") + .arg("status") + .output() + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout).ok() + } else { + None + } + }) + .map(|s| s.lines().map(String::from).collect()) + .unwrap_or_else(|| vec!["daemon not running".to_string()]) +} + +impl App { + pub(crate) fn draw_thalamus(&self, frame: &mut Frame, size: Rect) { + let status_lines = fetch_daemon_status(); + let section = Style::default().fg(Color::Yellow); + + let mut lines: Vec = Vec::new(); + lines.push(Line::styled("── Thalamus ──", section)); + lines.push(Line::raw("")); + + for line in &status_lines { + lines.push(Line::raw(format!(" {}", line))); + } + + let block = Block::default() + .title_top(Line::from(SCREEN_LEGEND).left_aligned()) + .title_top(Line::from(" thalamus ").right_aligned()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let para = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((self.debug_scroll, 0)); + + frame.render_widget(para, size); + } +} diff --git a/src/user/tui/unconscious_screen.rs b/src/user/tui/unconscious_screen.rs new file mode 100644 index 0000000..9398517 --- /dev/null +++ b/src/user/tui/unconscious_screen.rs @@ -0,0 +1,225 @@ +// unconscious_screen.rs — F4: memory daemon status +// +// Fetches status from the poc-memory daemon via socket RPC and +// displays graph health gauges, running tasks, and recent completions. + +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, Paragraph, Wrap}, + Frame, +}; + +use super::{App, SCREEN_LEGEND}; +use crate::subconscious::daemon::GraphHealth; + +/// Status fetched from the daemon socket. +#[derive(serde::Deserialize, Default)] +struct DaemonStatus { + #[allow(dead_code)] + pid: u32, + tasks: Vec, + #[serde(default)] + graph_health: Option, +} + +fn fetch_status() -> Option { + let json = jobkit::daemon::socket::send_rpc(&crate::config::get().data_dir, "")?; + serde_json::from_str(&json).ok() +} + +impl App { + pub(crate) fn draw_unconscious(&self, frame: &mut Frame, size: Rect) { + let block = Block::default() + .title_top(Line::from(SCREEN_LEGEND).left_aligned()) + .title_top(Line::from(" unconscious ").right_aligned()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(size); + frame.render_widget(block, size); + + let status = fetch_status(); + + match &status { + None => { + let dim = Style::default().fg(Color::DarkGray); + frame.render_widget( + Paragraph::new(Line::styled(" daemon not running", dim)), + inner, + ); + } + Some(st) => { + // Split into health area and tasks area + let has_health = st.graph_health.is_some(); + let [health_area, tasks_area] = Layout::vertical([ + Constraint::Length(if has_health { 9 } else { 0 }), + Constraint::Min(1), + ]) + .areas(inner); + + if let Some(ref gh) = st.graph_health { + Self::render_health(frame, gh, health_area); + } + + Self::render_tasks(frame, &st.tasks, tasks_area); + } + } + } + + fn render_health(frame: &mut Frame, gh: &GraphHealth, area: Rect) { + let [metrics_area, gauges_area, plan_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(4), + Constraint::Min(1), + ]) + .areas(area); + + // Metrics summary + let summary = Line::from(format!( + " {} nodes {} edges {} communities", + gh.nodes, gh.edges, gh.communities + )); + let ep_line = Line::from(vec![ + Span::raw(" episodic: "), + Span::styled( + format!("{:.0}%", gh.episodic_ratio * 100.0), + if gh.episodic_ratio < 0.4 { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + }, + ), + Span::raw(format!(" σ={:.1} interference={}", gh.sigma, gh.interference)), + ]); + frame.render_widget(Paragraph::new(vec![summary, ep_line]), metrics_area); + + // Health gauges + let [g1, g2, g3] = Layout::horizontal([ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ]) + .areas(gauges_area); + + let alpha_color = if gh.alpha >= 2.5 { Color::Green } else { Color::Red }; + frame.render_widget( + Gauge::default() + .block(Block::default().borders(Borders::ALL).title(" α (≥2.5) ")) + .gauge_style(Style::default().fg(alpha_color)) + .ratio((gh.alpha / 5.0).clamp(0.0, 1.0) as f64) + .label(format!("{:.2}", gh.alpha)), + g1, + ); + + let gini_color = if gh.gini <= 0.4 { Color::Green } else { Color::Red }; + frame.render_widget( + Gauge::default() + .block(Block::default().borders(Borders::ALL).title(" gini (≤0.4) ")) + .gauge_style(Style::default().fg(gini_color)) + .ratio(gh.gini.clamp(0.0, 1.0) as f64) + .label(format!("{:.3}", gh.gini)), + g2, + ); + + let cc_color = if gh.avg_cc >= 0.2 { Color::Green } else { Color::Red }; + frame.render_widget( + Gauge::default() + .block(Block::default().borders(Borders::ALL).title(" cc (≥0.2) ")) + .gauge_style(Style::default().fg(cc_color)) + .ratio(gh.avg_cc.clamp(0.0, 1.0) as f64) + .label(format!("{:.3}", gh.avg_cc)), + g3, + ); + + // Plan summary + let plan_total: usize = gh.plan_counts.values().sum::() + 1; + let plan_summary: Vec = gh.plan_counts.iter() + .filter(|(_, c)| **c > 0) + .map(|(a, c)| format!("{}{}", &a[..1], c)) + .collect(); + let plan_line = Line::from(vec![ + Span::raw(" plan: "), + Span::styled( + format!("{}", plan_total), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" agents ({} +health)", plan_summary.join(" "))), + ]); + frame.render_widget(Paragraph::new(plan_line), plan_area); + } + + fn render_tasks(frame: &mut Frame, tasks: &[jobkit::TaskInfo], area: Rect) { + let mut lines: Vec = Vec::new(); + let section = Style::default().fg(Color::Yellow); + let dim = Style::default().fg(Color::DarkGray); + + let running: Vec<_> = tasks.iter() + .filter(|t| matches!(t.status, jobkit::TaskStatus::Running)) + .collect(); + let completed: Vec<_> = tasks.iter() + .filter(|t| matches!(t.status, jobkit::TaskStatus::Completed)) + .collect(); + let failed: Vec<_> = tasks.iter() + .filter(|t| matches!(t.status, jobkit::TaskStatus::Failed)) + .collect(); + + lines.push(Line::styled("── Tasks ──", section)); + lines.push(Line::raw(format!( + " Running: {} Completed: {} Failed: {}", + running.len(), completed.len(), failed.len() + ))); + lines.push(Line::raw("")); + + // Running tasks with elapsed time + if !running.is_empty() { + for task in &running { + let elapsed = task.started_at + .map(|s| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + format!("{}s", (now - s) as u64) + }) + .unwrap_or_default(); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("●", Style::default().fg(Color::Green)), + Span::raw(format!(" {} ({})", task.name, elapsed)), + ])); + } + lines.push(Line::raw("")); + } + + // Recent completed (last 10) + if !completed.is_empty() { + lines.push(Line::styled(" Recent:", dim)); + for task in completed.iter().rev().take(10) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("✓", Style::default().fg(Color::Green)), + Span::raw(format!(" {}", task.name)), + ])); + } + } + + // Failed tasks + if !failed.is_empty() { + lines.push(Line::raw("")); + lines.push(Line::styled(" Failed:", Style::default().fg(Color::Red))); + for task in failed.iter().rev().take(5) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("✗", Style::default().fg(Color::Red)), + Span::raw(format!(" {}", task.name)), + ])); + } + } + + frame.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: false }), + area, + ); + } +} diff --git a/src/agent/types.rs b/src/user/types.rs similarity index 100% rename from src/agent/types.rs rename to src/user/types.rs diff --git a/src/agent/ui_channel.rs b/src/user/ui_channel.rs similarity index 100% rename from src/agent/ui_channel.rs rename to src/user/ui_channel.rs