diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..01a7403 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# Tycode - Claude Code Guidelines + +## Git Remotes & Sync Flow + +This repo has two remotes: +- **origin** (tigy32/Tycode) - the original upstream repo +- **fork** (k29/Tycode) - our fork + +Our fork should stay synced with upstream. **Always prompt the user to sync before starting work.** + +### Sync fork with upstream: +``` +git fetch origin +git rebase origin/main +git push fork main +``` + +### For new feature work: +1. Sync main first (see above) +2. `git checkout -b feature/xyz` +3. Work on it, push to fork: `git push fork feature/xyz` +4. When ready, merge into main or open a PR to upstream + +### Quick sync (no local changes): +``` +git pull origin main --ff-only +git push fork main +``` + +Use `--ff-only` to ensure fast-forward only, never merge commits. Keep history linear. + +### Updating feature branches after changes on main + +When a commit on `main` relates to an open PR's feature branch, update that branch so the PR stays current. The feature branch should contain **only its own commits** on top of `origin/main` (no unrelated commits like plugin system changes). + +``` +git checkout feature/xyz +git reset --hard origin/main +git cherry-pick # only the commits for this feature +cargo build -p tycode-cli # verify it builds +git push fork feature/xyz --force-with-lease +git checkout main +``` + +**Active feature branches & PRs:** +| Branch | PR | Description | +|--------|----|-------------| +| `feature/tui` | #38 | Ratatui-based TUI | diff --git a/tycode-cli/Cargo.toml b/tycode-cli/Cargo.toml index 9cc53ba..d72ce9c 100644 --- a/tycode-cli/Cargo.toml +++ b/tycode-cli/Cargo.toml @@ -37,3 +37,9 @@ serde = { workspace = true } serde_json = { workspace = true } similar = "2.4" +# TUI dependencies +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } +tui-textarea = "0.7" +futures = "0.3" + diff --git a/tycode-cli/src/main.rs b/tycode-cli/src/main.rs index 37d50d1..1bec2f3 100644 --- a/tycode-cli/src/main.rs +++ b/tycode-cli/src/main.rs @@ -14,8 +14,10 @@ mod commands; mod github; mod interactive_app; mod state; +mod tui; use crate::interactive_app::InteractiveApp; +use crate::tui::TuiApp; #[derive(Parser, Debug)] #[command(name = "tycode-cli")] @@ -49,6 +51,10 @@ struct Args { /// Task description for auto mode #[arg(long)] task: Option, + + /// Disable TUI and use legacy line-based interactive mode + #[arg(long)] + no_tui: bool, } fn main() -> Result<()> { @@ -100,8 +106,13 @@ async fn async_main() -> Result<()> { return auto::run_auto(args.task.unwrap(), roots, args.profile, args.compact).await; } - let mut app = InteractiveApp::new(workspace_roots, args.profile, args.compact).await?; - app.run().await?; + if args.no_tui { + let mut app = InteractiveApp::new(workspace_roots, args.profile, args.compact).await?; + app.run().await?; + } else { + let mut tui_app = TuiApp::new(workspace_roots, args.profile).await?; + tui_app.run().await?; + } Ok(()) } diff --git a/tycode-cli/src/tui/app.rs b/tycode-cli/src/tui/app.rs new file mode 100644 index 0000000..f083632 --- /dev/null +++ b/tycode-cli/src/tui/app.rs @@ -0,0 +1,416 @@ +use anyhow::Result; +use crossterm::{ + event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event as CrosstermEvent, EventStream, KeyboardEnhancementFlags, + MouseButton, MouseEvent, MouseEventKind, PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::StreamExt; +use ratatui::{backend::CrosstermBackend, layout::Position, Terminal}; +use std::io; +use std::path::PathBuf; +use std::time::Duration; +use tokio::sync::mpsc; +use tui_textarea::TextArea; +use tycode_core::ai::model::Model; +use tycode_core::chat::actor::{ChatActor, ChatActorBuilder}; +use tycode_core::chat::events::ChatEvent; +use tycode_core::modules::memory::MemoryConfig; +use tycode_core::settings::SettingsManager; + +use super::event_handler::handle_chat_event; +use super::input_handler::{configure_textarea, handle_key_event, TuiAction}; +use super::state::{BannerData, TuiState}; +use super::ui::draw_ui; + +use crate::commands::{handle_local_command, LocalCommandResult}; +use crate::state::State; + +pub struct TuiApp { + terminal: Terminal>, + chat_actor: ChatActor, + event_rx: mpsc::UnboundedReceiver, + state: TuiState, +} + +impl TuiApp { + pub async fn new( + workspace_roots: Option>, + profile: Option, + ) -> Result { + let workspace_roots = workspace_roots.unwrap_or_else(|| vec![PathBuf::from(".")]); + + // Load settings for banner display + let root_dir = dirs::home_dir() + .expect("Failed to get home directory") + .join(".tycode"); + let settings_manager = SettingsManager::from_settings_dir(root_dir, profile.as_deref())?; + let settings = settings_manager.settings(); + + let model_display = settings + .get_agent_model(&settings.default_agent) + .map(|m| match m.model { + Model::ClaudeOpus45 => "claude-opus-4-5".to_string(), + Model::ClaudeSonnet45 => "claude-sonnet-4-5".to_string(), + Model::ClaudeHaiku45 => "claude-haiku-4-5".to_string(), + _ => format!("{:?}", m.model).to_lowercase(), + }) + .or_else(|| { + settings.model_quality.map(|q| { + match q { + tycode_core::ai::model::ModelCost::Unlimited + | tycode_core::ai::model::ModelCost::High => "claude-opus-4-5", + tycode_core::ai::model::ModelCost::Medium => "claude-sonnet-4-5", + tycode_core::ai::model::ModelCost::Low + | tycode_core::ai::model::ModelCost::Free => "claude-haiku-4-5", + } + .to_string() + }) + }); + + let memory_config: MemoryConfig = settings.get_module_config("memory"); + + let banner_data = BannerData { + version: env!("CARGO_PKG_VERSION").to_string(), + provider: settings.active_provider.clone(), + model: model_display, + agent: settings.default_agent.clone(), + workspace: workspace_roots + .first() + .and_then(|p| p.canonicalize().ok()) + .or_else(|| std::env::current_dir().ok()) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| ".".to_string()), + memory_enabled: memory_config.enabled, + memory_count: memory_config.recent_memories_count, + }; + + let (chat_actor, event_rx) = + ChatActorBuilder::tycode(workspace_roots, None, profile)?.build()?; + + let state = TuiState::new(Some(banner_data)); + + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture)?; + // Try to enable keyboard enhancement (for Shift+Enter support). + // This is only supported by some terminals (Kitty, WezTerm, foot, etc.) + // so we ignore failures. + let _ = execute!( + stdout, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ); + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + + Ok(Self { + terminal, + chat_actor, + event_rx, + state, + }) + } + + pub async fn run(&mut self) -> Result<()> { + // Install panic hook to restore terminal on panic + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags, DisableMouseCapture, DisableBracketedPaste, LeaveAlternateScreen); + original_hook(panic_info); + })); + + // Initial settings handshake + self.chat_actor.get_settings()?; + self.wait_for_settings().await?; + + // Create textarea for input + let mut textarea = TextArea::default(); + configure_textarea(&mut textarea); + + let tick_rate = Duration::from_millis(50); + let mut crossterm_reader = EventStream::new(); + + loop { + // Render + let state = &mut self.state; + let ta = &textarea; + self.terminal.draw(|frame| { + draw_ui(frame, state, ta); + })?; + + if self.state.should_quit { + break; + } + + tokio::select! { + // Poll ChatEvents from the actor + Some(chat_event) = self.event_rx.recv() => { + handle_chat_event(&mut self.state, chat_event); + } + + // Poll crossterm events (async) + Some(Ok(crossterm_event)) = crossterm_reader.next() => { + match crossterm_event { + CrosstermEvent::Key(key) => { + match handle_key_event(key, &mut textarea, &mut self.state) { + TuiAction::SendMessage(text) => { + self.send_user_message(&text)?; + } + TuiAction::Cancel => { + self.chat_actor.cancel()?; + } + TuiAction::Quit => { + self.state.should_quit = true; + } + TuiAction::None => {} + } + } + CrosstermEvent::Mouse(mouse) => { + self.handle_mouse_event(mouse); + } + CrosstermEvent::Paste(text) => { + textarea.insert_str(&text); + } + CrosstermEvent::Resize(_, _) => { + // Terminal will re-render on next loop iteration + } + _ => {} + } + } + + // Tick for spinner animation + _ = tokio::time::sleep(tick_rate) => { + if self.state.is_thinking { + self.state.spinner_frame += 1; + } + } + } + } + + // Restore terminal + self.restore_terminal()?; + + Ok(()) + } + + fn send_user_message(&mut self, text: &str) -> Result<()> { + let input = text.trim().to_string(); + if input.is_empty() { + return Ok(()); + } + + // Check for local commands + let mut temp_state = State { + show_reasoning: self.state.inner_state.show_reasoning, + show_timing: self.state.inner_state.show_timing, + }; + match handle_local_command(&mut temp_state, &input) { + LocalCommandResult::Handled { msg } => { + // Sync toggles back + self.state.inner_state.show_reasoning = temp_state.show_reasoning; + self.state.inner_state.show_timing = temp_state.show_timing; + self.state + .push_entry(super::state::ChatEntry::SystemMessage { content: msg }); + return Ok(()); + } + LocalCommandResult::Exit => { + self.state.should_quit = true; + return Ok(()); + } + LocalCommandResult::Unhandled => {} + } + + // Add user message to history + self.state + .push_entry(super::state::ChatEntry::UserMessage { + content: input.clone(), + }); + self.state.awaiting_response = true; + self.state.auto_scroll = true; + + // Send to actor + self.chat_actor.send_message(input)?; + Ok(()) + } + + async fn wait_for_settings(&mut self) -> Result<()> { + while let Some(event) = self.event_rx.recv().await { + // Skip TaskUpdate events during startup + if !matches!(event, ChatEvent::TaskUpdate(_)) { + handle_chat_event(&mut self.state, event.clone()); + } + + if let ChatEvent::TypingStatusChanged(typing) = event { + if !typing { + break; + } + } + } + Ok(()) + } + + fn handle_mouse_event(&mut self, mouse: MouseEvent) { + let col = mouse.column; + let row = mouse.row; + let in_chat = self + .state + .chat_area + .contains(Position { x: col, y: row }); + + match mouse.kind { + MouseEventKind::ScrollUp if in_chat => { + self.state.scroll_up(3); + } + MouseEventKind::ScrollDown if in_chat => { + self.state.scroll_down(3); + } + MouseEventKind::Down(MouseButton::Left) => { + if in_chat { + self.state.selection.start = (col, row); + self.state.selection.end = (col, row); + self.state.selection.is_dragging = true; + self.state.selection.has_selection = false; + } else { + self.state.selection.clear(); + } + } + MouseEventKind::Drag(MouseButton::Left) if self.state.selection.is_dragging => { + // Clamp to chat area bounds + let clamped_col = col.clamp(self.state.chat_area.x, self.state.chat_area.right().saturating_sub(1)); + let clamped_row = row.clamp(self.state.chat_area.y, self.state.chat_area.bottom().saturating_sub(1)); + self.state.selection.end = (clamped_col, clamped_row); + self.state.selection.has_selection = true; + } + MouseEventKind::Up(MouseButton::Left) => { + if self.state.selection.has_selection { + self.state.selection.is_dragging = false; + let text = self.extract_selected_text(); + if !text.is_empty() { + Self::copy_to_clipboard(&text); + } + } else { + self.state.selection.clear(); + } + } + _ => {} + } + } + + fn extract_selected_text(&self) -> String { + let ((sx, sy), (ex, ey)) = self.state.selection.normalized(); + let area = self.state.chat_area; + + // Convert terminal-absolute coords to chat-area-relative + let rel_sy = sy.saturating_sub(area.y) as usize; + let rel_ey = ey.saturating_sub(area.y) as usize; + let rel_sx = sx.saturating_sub(area.x) as usize; + let rel_ex = ex.saturating_sub(area.x) as usize; + + let mut lines = Vec::new(); + for row_idx in rel_sy..=rel_ey { + if row_idx >= self.state.screen_text.len() { + break; + } + let line = &self.state.screen_text[row_idx]; + let start_col = if row_idx == rel_sy { rel_sx } else { 0 }; + let end_col = if row_idx == rel_ey { + (rel_ex + 1).min(line.len()) + } else { + line.len() + }; + + if start_col < line.len() { + let selected: String = line + .chars() + .skip(start_col) + .take(end_col.saturating_sub(start_col)) + .collect(); + lines.push(selected.trim_end().to_string()); + } else { + lines.push(String::new()); + } + } + + // Remove trailing empty lines + while lines.last().map_or(false, |l| l.is_empty()) { + lines.pop(); + } + + lines.join("\n") + } + + fn copy_to_clipboard(text: &str) { + #[cfg(target_os = "macos")] + { + use std::process::{Command, Stdio}; + if let Ok(mut child) = Command::new("pbcopy") + .stdin(Stdio::piped()) + .spawn() + { + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + } + } + #[cfg(target_os = "linux")] + { + use std::process::{Command, Stdio}; + use std::io::Write; + // Try xclip first, fall back to xsel + let result = Command::new("xclip") + .args(["-selection", "clipboard"]) + .stdin(Stdio::piped()) + .spawn(); + if let Ok(mut child) = result { + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + } else if let Ok(mut child) = Command::new("xsel") + .args(["--clipboard", "--input"]) + .stdin(Stdio::piped()) + .spawn() + { + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + } + } + } + + fn restore_terminal(&mut self) -> Result<()> { + disable_raw_mode()?; + execute!( + self.terminal.backend_mut(), + PopKeyboardEnhancementFlags, + DisableMouseCapture, + DisableBracketedPaste, + LeaveAlternateScreen + )?; + self.terminal.show_cursor()?; + Ok(()) + } +} + +impl Drop for TuiApp { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!( + self.terminal.backend_mut(), + PopKeyboardEnhancementFlags, + DisableMouseCapture, + DisableBracketedPaste, + LeaveAlternateScreen + ); + let _ = self.terminal.show_cursor(); + } +} diff --git a/tycode-cli/src/tui/event_handler.rs b/tycode-cli/src/tui/event_handler.rs new file mode 100644 index 0000000..ba63bcc --- /dev/null +++ b/tycode-cli/src/tui/event_handler.rs @@ -0,0 +1,268 @@ +use tycode_core::chat::events::{ChatEvent, MessageSender, ToolExecutionResult, ToolRequestType}; + +use super::state::{ChatEntry, TuiState}; + +pub fn handle_chat_event(state: &mut TuiState, event: ChatEvent) { + match event { + ChatEvent::MessageAdded(message) => match message.sender { + MessageSender::Assistant { agent } => { + // Update status bar model/agent + state.current_agent = agent.clone(); + if let Some(ref info) = message.model_info { + state.current_model = info.model.name().to_string(); + } + + if state.inner_state.show_reasoning { + if let Some(ref reasoning) = message.reasoning { + state.push_entry(ChatEntry::SystemMessage { + content: format!("Reasoning: {}", reasoning.text), + }); + } + } + state.push_entry(ChatEntry::AssistantMessage { + agent, + model: message + .model_info + .as_ref() + .map(|m| m.model.name().to_string()) + .unwrap_or_default(), + content: message.content, + token_usage: message.token_usage.clone(), + }); + if let Some(ref usage) = message.token_usage { + state.accumulate_tokens(usage); + } + } + MessageSender::System => { + state.push_entry(ChatEntry::SystemMessage { + content: message.content, + }); + } + MessageSender::Warning => { + state.push_entry(ChatEntry::WarningMessage { + content: message.content, + }); + } + MessageSender::Error => { + state.push_entry(ChatEntry::ErrorMessage { + content: message.content, + }); + } + MessageSender::User => { + // Usually already added by send_user_message; skip to avoid duplicates. + } + }, + + ChatEvent::StreamStart { agent, model, .. } => { + state.is_thinking = false; + state.thinking_text.clear(); + state.current_agent = agent.clone(); + state.current_model = model.name().to_string(); + state.push_entry(ChatEntry::StreamingMessage { + agent, + model: model.name().to_string(), + content: String::new(), + }); + } + + ChatEvent::StreamDelta { text, .. } => { + if let Some(ChatEntry::StreamingMessage { content, .. }) = + state.chat_history.last_mut() + { + content.push_str(&text); + } + } + + ChatEvent::StreamReasoningDelta { text, .. } => { + if state.inner_state.show_reasoning { + if let Some(ChatEntry::StreamingMessage { content, .. }) = + state.chat_history.last_mut() + { + content.push_str(&text); + } + } + } + + ChatEvent::StreamEnd { message } => { + // Convert the last StreamingMessage to a finalized AssistantMessage. + if let Some(last) = state.chat_history.last_mut() { + if let ChatEntry::StreamingMessage { + agent, + model, + content, + } = last + { + *last = ChatEntry::AssistantMessage { + agent: agent.clone(), + model: model.clone(), + content: content.clone(), + token_usage: message.token_usage.clone(), + }; + } + } + if let Some(ref usage) = message.token_usage { + state.accumulate_tokens(usage); + } + if !message.tool_calls.is_empty() { + let count = message.tool_calls.len(); + let names: Vec<&str> = + message.tool_calls.iter().map(|tc| tc.name.as_str()).collect(); + let call_text = if count == 1 { "call" } else { "calls" }; + state.push_entry(ChatEntry::SystemMessage { + content: format!("{count} tool {call_text}: {}", names.join(", ")), + }); + } + } + + ChatEvent::TypingStatusChanged(typing) => { + state.is_thinking = typing; + if typing { + state.thinking_text = "Thinking...".to_string(); + } else { + state.thinking_text.clear(); + state.awaiting_response = false; + } + } + + ChatEvent::ToolRequest(tool_request) => { + let summary = match &tool_request.tool_type { + ToolRequestType::ModifyFile { file_path, .. } => { + format!("Modifying {file_path}") + } + ToolRequestType::RunCommand { command, .. } => { + format!("Running `{command}`") + } + ToolRequestType::ReadFiles { file_paths } => { + format!("Reading {} file(s)", file_paths.len()) + } + ToolRequestType::SearchTypes { type_name, .. } => { + format!("Searching types: {type_name}") + } + ToolRequestType::GetTypeDocs { type_path, .. } => { + format!("Getting docs: {type_path}") + } + ToolRequestType::Other { .. } => { + format!("Executing {}", tool_request.tool_name) + } + }; + state.thinking_text = summary.clone(); + state.push_entry(ChatEntry::ToolRequest { + tool_name: tool_request.tool_name, + summary, + }); + } + + ChatEvent::ToolExecutionCompleted { + tool_name, + tool_result, + success, + .. + } => { + let summary = format_tool_result_summary(&tool_name, &tool_result); + state.push_entry(ChatEntry::ToolResult { + tool_name, + success, + summary, + }); + if state.is_thinking { + state.thinking_text = "Thinking...".to_string(); + } + } + + ChatEvent::OperationCancelled { .. } => { + state.push_entry(ChatEntry::SystemMessage { + content: "Operation cancelled".to_string(), + }); + state.is_thinking = false; + state.awaiting_response = false; + } + + ChatEvent::RetryAttempt { + attempt, + max_retries, + error, + .. + } => { + state.push_entry(ChatEntry::WarningMessage { + content: format!("Retry {attempt}/{max_retries}: {error}"), + }); + } + + ChatEvent::TaskUpdate(task_list) => { + state.current_tasks = Some(task_list.clone()); + state.push_entry(ChatEntry::TaskUpdate { task_list }); + } + + ChatEvent::TimingUpdate { + waiting_for_human, + ai_processing, + tool_execution, + } => { + if state.inner_state.show_timing { + let total = waiting_for_human + ai_processing + tool_execution; + state.push_entry(ChatEntry::SystemMessage { + content: format!( + "Timing: Human {:.1}s, AI {:.1}s, Tools {:.1}s, Total {:.1}s", + waiting_for_human.as_secs_f64(), + ai_processing.as_secs_f64(), + tool_execution.as_secs_f64(), + total.as_secs_f64(), + ), + }); + } + } + + ChatEvent::Error(e) => { + state.push_entry(ChatEntry::ErrorMessage { content: e }); + } + + ChatEvent::ConversationCleared => { + state.chat_history.clear(); + state.push_entry(ChatEntry::SystemMessage { + content: "Conversation cleared".to_string(), + }); + } + + // Events not relevant to the TUI display + ChatEvent::Settings(_) + | ChatEvent::SessionsList { .. } + | ChatEvent::ProfilesList { .. } + | ChatEvent::ModuleSchemas { .. } => {} + } +} + +fn format_tool_result_summary(tool_name: &str, result: &ToolExecutionResult) -> String { + match result { + ToolExecutionResult::ModifyFile { + lines_added, + lines_removed, + } => { + format!("{tool_name}: +{lines_added}/-{lines_removed} lines") + } + ToolExecutionResult::RunCommand { + exit_code, stderr, .. + } => { + if *exit_code == 0 { + format!("{tool_name}: completed (exit 0)") + } else { + let err_preview = stderr.lines().next().unwrap_or("").chars().take(80).collect::(); + format!("{tool_name}: exit {exit_code} - {err_preview}") + } + } + ToolExecutionResult::ReadFiles { files } => { + format!("{tool_name}: read {} file(s)", files.len()) + } + ToolExecutionResult::SearchTypes { types } => { + format!("{tool_name}: found {} type(s)", types.len()) + } + ToolExecutionResult::GetTypeDocs { .. } => { + format!("{tool_name}: docs retrieved") + } + ToolExecutionResult::Error { short_message, .. } => { + format!("{tool_name}: error - {short_message}") + } + ToolExecutionResult::Other { .. } => { + format!("{tool_name}: completed") + } + } +} diff --git a/tycode-cli/src/tui/input_handler.rs b/tycode-cli/src/tui/input_handler.rs new file mode 100644 index 0000000..df8f427 --- /dev/null +++ b/tycode-cli/src/tui/input_handler.rs @@ -0,0 +1,102 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use tui_textarea::TextArea; + +use super::state::TuiState; + +pub enum TuiAction { + /// Send the current input text as a message. + SendMessage(String), + /// Cancel the current AI operation. + Cancel, + /// Quit the application. + Quit, + /// No action needed. + None, +} + +pub fn handle_key_event( + key: KeyEvent, + textarea: &mut TextArea, + state: &mut TuiState, +) -> TuiAction { + match (key.code, key.modifiers) { + // Ctrl+C: cancel if awaiting response, quit if idle + (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { + if state.awaiting_response { + TuiAction::Cancel + } else { + TuiAction::Quit + } + } + + // Ctrl+D: quit + (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => TuiAction::Quit, + + // Enter without modifier: send message + (KeyCode::Enter, KeyModifiers::NONE) => { + if state.awaiting_response { + return TuiAction::None; + } + let lines: Vec = textarea.lines().to_vec(); + let text = lines.join("\n"); + if text.trim().is_empty() { + return TuiAction::None; + } + // Clear the textarea + *textarea = TextArea::default(); + configure_textarea(textarea); + TuiAction::SendMessage(text) + } + + // Shift+Enter or Alt+Enter: insert newline + (KeyCode::Enter, m) + if m.contains(KeyModifiers::SHIFT) || m.contains(KeyModifiers::ALT) => + { + textarea.insert_newline(); + TuiAction::None + } + + // PageUp: scroll chat history up + (KeyCode::PageUp, _) => { + state.scroll_up(10); + TuiAction::None + } + + // PageDown: scroll chat history down + (KeyCode::PageDown, _) => { + state.scroll_down(10); + TuiAction::None + } + + // Ctrl+Up: scroll one line up + (KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => { + state.scroll_up(1); + TuiAction::None + } + + // Ctrl+Down: scroll one line down + (KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => { + state.scroll_down(1); + TuiAction::None + } + + // Escape: clear input + (KeyCode::Esc, _) => { + *textarea = TextArea::default(); + configure_textarea(textarea); + TuiAction::None + } + + // All other keys: forward to textarea + _ => { + textarea.input(key); + TuiAction::None + } + } +} + +pub fn configure_textarea(textarea: &mut TextArea) { + textarea.set_placeholder_text("Type a message... (Enter to send, Shift+Enter for new line)"); + textarea.set_cursor_line_style(ratatui::style::Style::default()); + textarea.set_style(ratatui::style::Style::default().fg(ratatui::style::Color::White)); +} diff --git a/tycode-cli/src/tui/mod.rs b/tycode-cli/src/tui/mod.rs new file mode 100644 index 0000000..85c66fb --- /dev/null +++ b/tycode-cli/src/tui/mod.rs @@ -0,0 +1,8 @@ +mod app; +mod event_handler; +mod input_handler; +mod state; +mod ui; +mod widgets; + +pub use app::TuiApp; diff --git a/tycode-cli/src/tui/state.rs b/tycode-cli/src/tui/state.rs new file mode 100644 index 0000000..37afa09 --- /dev/null +++ b/tycode-cli/src/tui/state.rs @@ -0,0 +1,215 @@ +use ratatui::layout::Rect; +use tycode_core::ai::types::TokenUsage; +use tycode_core::modules::task_list::TaskList; + +use crate::state::State; + +/// Tracks mouse-based text selection in the chat panel. +#[derive(Clone, Debug, Default)] +pub struct SelectionState { + /// Starting position of the selection (terminal-absolute coordinates). + pub start: (u16, u16), + /// Ending position of the selection (terminal-absolute coordinates). + pub end: (u16, u16), + /// Whether the user is currently dragging (mouse button held). + pub is_dragging: bool, + /// Whether there is a valid selection (drag moved at least 1 cell). + pub has_selection: bool, +} + +impl SelectionState { + /// Returns start and end in reading order (top-left to bottom-right). + pub fn normalized(&self) -> ((u16, u16), (u16, u16)) { + let (sx, sy) = self.start; + let (ex, ey) = self.end; + if sy < ey || (sy == ey && sx <= ex) { + ((sx, sy), (ex, ey)) + } else { + ((ex, ey), (sx, sy)) + } + } + + /// Clear the selection state. + pub fn clear(&mut self) { + self.start = (0, 0); + self.end = (0, 0); + self.is_dragging = false; + self.has_selection = false; + } +} + +/// A single entry in the chat history panel. +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub enum ChatEntry { + UserMessage { + content: String, + }, + AssistantMessage { + agent: String, + model: String, + content: String, + token_usage: Option, + }, + /// An assistant message currently being streamed token-by-token. + StreamingMessage { + agent: String, + model: String, + content: String, + }, + SystemMessage { + content: String, + }, + WarningMessage { + content: String, + }, + ErrorMessage { + content: String, + }, + ToolRequest { + tool_name: String, + summary: String, + }, + ToolResult { + tool_name: String, + success: bool, + summary: String, + }, + TaskUpdate { + task_list: TaskList, + }, +} + +/// Data for the startup banner displayed in the chat panel. +pub struct BannerData { + pub version: String, + pub provider: Option, + pub model: Option, + pub agent: String, + pub workspace: String, + pub memory_enabled: bool, + pub memory_count: usize, +} + +pub struct TuiState { + /// Ordered list of chat entries, newest last. + pub chat_history: Vec, + + /// Scroll offset from the bottom (0 = fully scrolled down). + pub scroll_offset: u16, + + /// Maximum scroll offset (computed during render). + pub max_scroll: u16, + + /// Whether auto-scroll is active (true when user is at the bottom). + pub auto_scroll: bool, + + /// Whether the AI is currently processing. + pub is_thinking: bool, + + /// Text shown as the thinking status in the status bar. + pub thinking_text: String, + + /// Spinner animation frame counter. + pub spinner_frame: usize, + + /// Current agent name for the status bar. + pub current_agent: String, + + /// Current model name for the status bar. + pub current_model: String, + + /// Session-level token usage for the status bar. + pub total_input_tokens: u32, + pub total_output_tokens: u32, + + /// Current task list (if any). + pub current_tasks: Option, + + /// Inner state from the existing State struct (show_reasoning, show_timing). + pub inner_state: State, + + /// Whether the app should exit. + pub should_quit: bool, + + /// Whether we are currently waiting for a response. + pub awaiting_response: bool, + + /// Banner info for initial display. + pub banner_data: Option, + + /// The chat panel area rect (stored from layout for mouse hit-testing). + pub chat_area: Rect, + + /// Mouse-based text selection state. + pub selection: SelectionState, + + /// Buffer snapshot of the chat panel text (one String per row). + pub screen_text: Vec, +} + +impl TuiState { + pub fn new(banner_data: Option) -> Self { + let current_agent = banner_data + .as_ref() + .map(|b| b.agent.clone()) + .unwrap_or_else(|| "tycode".to_string()); + let current_model = banner_data + .as_ref() + .and_then(|b| b.model.clone()) + .unwrap_or_default(); + + Self { + chat_history: Vec::new(), + scroll_offset: 0, + max_scroll: 0, + auto_scroll: true, + is_thinking: false, + thinking_text: String::new(), + spinner_frame: 0, + current_agent, + current_model, + total_input_tokens: 0, + total_output_tokens: 0, + current_tasks: None, + inner_state: State::default(), + should_quit: false, + awaiting_response: false, + banner_data, + chat_area: Rect::default(), + selection: SelectionState::default(), + screen_text: Vec::new(), + } + } + + /// Append an entry and maintain auto-scroll. + pub fn push_entry(&mut self, entry: ChatEntry) { + self.chat_history.push(entry); + if self.auto_scroll { + self.scroll_offset = 0; + self.selection.clear(); + } + } + + /// Accumulate token usage from a response. + pub fn accumulate_tokens(&mut self, usage: &TokenUsage) { + self.total_input_tokens += usage.input_tokens + + usage.cache_creation_input_tokens.unwrap_or(0); + self.total_output_tokens += usage.output_tokens + usage.reasoning_tokens.unwrap_or(0); + } + + pub fn scroll_up(&mut self, amount: u16) { + self.scroll_offset = self + .scroll_offset + .saturating_add(amount) + .min(self.max_scroll); + self.auto_scroll = false; + } + + pub fn scroll_down(&mut self, amount: u16) { + self.scroll_offset = self.scroll_offset.saturating_sub(amount); + if self.scroll_offset == 0 { + self.auto_scroll = true; + } + } +} diff --git a/tycode-cli/src/tui/ui.rs b/tycode-cli/src/tui/ui.rs new file mode 100644 index 0000000..4c30322 --- /dev/null +++ b/tycode-cli/src/tui/ui.rs @@ -0,0 +1,70 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout}, + style::Modifier, + Frame, +}; +use tui_textarea::TextArea; + +use super::state::TuiState; +use super::widgets::{chat_panel, input_area, status_bar}; + +pub fn draw_ui(frame: &mut Frame, state: &mut TuiState, textarea: &TextArea) { + let inner = frame.area(); + + // Input height: textarea lines + 2 for top/bottom borders, min 3, max 12 + let textarea_lines = textarea.lines().len().clamp(1, 10) as u16; + let input_height = textarea_lines + 2; // +2 for top and bottom border lines + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // Chat panel (fills remaining space) + Constraint::Length(input_height), // Input area (dynamic, with borders) + Constraint::Length(1), // Empty line gap + Constraint::Length(1), // Status bar + ]) + .split(inner); + + chat_panel::render(frame, chunks[0], state); + input_area::render(frame, chunks[1], textarea); + // chunks[2] is the empty gap line - just leave it blank + status_bar::render(frame, chunks[3], state); + + // Store chat area rect for mouse hit-testing + state.chat_area = chunks[0]; + + // Snapshot the chat panel buffer text for text extraction + let area = chunks[0]; + let buf = frame.buffer_mut(); + let mut screen_text = Vec::with_capacity(area.height as usize); + for row in area.y..area.bottom() { + let mut line = String::with_capacity(area.width as usize); + for col in area.x..area.right() { + let cell = &buf[(col, row)]; + line.push_str(cell.symbol()); + } + screen_text.push(line); + } + state.screen_text = screen_text; + + // Apply selection highlight (reversed video) + if state.selection.has_selection { + let ((sx, sy), (ex, ey)) = state.selection.normalized(); + for row in sy..=ey { + if row < area.y || row >= area.bottom() { + continue; + } + let col_start = if row == sy { sx.max(area.x) } else { area.x }; + let col_end = if row == ey { + (ex + 1).min(area.right()) + } else { + area.right() + }; + for col in col_start..col_end { + let cell = &mut buf[(col, row)]; + let style = cell.style().add_modifier(Modifier::REVERSED); + cell.set_style(style); + } + } + } +} diff --git a/tycode-cli/src/tui/widgets/chat_panel.rs b/tycode-cli/src/tui/widgets/chat_panel.rs new file mode 100644 index 0000000..f3ab38d --- /dev/null +++ b/tycode-cli/src/tui/widgets/chat_panel.rs @@ -0,0 +1,437 @@ +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + Frame, +}; +use tycode_core::modules::task_list::TaskStatus; + +use crate::tui::state::{ChatEntry, TuiState}; + +pub fn render(frame: &mut Frame, area: Rect, state: &mut TuiState) { + // Reserve 1 column on the right for the scrollbar so it doesn't overwrite text + let text_area = Rect { + x: area.x, + y: area.y, + width: area.width.saturating_sub(1), + height: area.height, + }; + + // Build all lines from chat history (all owned data to avoid lifetime issues) + let mut lines: Vec> = Vec::new(); + + // Always render banner at the top of the scrollable history + if let Some(ref banner) = state.banner_data { + render_banner(&mut lines, banner, text_area.width); + } + + for entry in &state.chat_history { + render_entry(&mut lines, entry); + } + + // Compute the true wrapped line count using the text area width (excludes scrollbar) + let total_wrapped = compute_wrapped_line_count(&lines, text_area.width); + let visible_height = area.height; + let max_scroll = total_wrapped.saturating_sub(visible_height); + + // Store max_scroll so input_handler can clamp + state.max_scroll = max_scroll; + + // Clamp scroll_offset + if state.scroll_offset > max_scroll { + state.scroll_offset = max_scroll; + } + + let scroll_from_top = max_scroll.saturating_sub(state.scroll_offset); + + let paragraph = Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((scroll_from_top, 0)); + + frame.render_widget(paragraph, text_area); + + // Render scrollbar in the full area (occupies the rightmost column) + if total_wrapped > visible_height { + let mut scrollbar_state = + ScrollbarState::new(max_scroll as usize).position(scroll_from_top as usize); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight); + frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state); + } +} + +/// Compute the number of visual lines after word wrapping. +fn compute_wrapped_line_count(lines: &[Line], width: u16) -> u16 { + if width == 0 { + return lines.len() as u16; + } + let w = width as usize; + let mut count: u16 = 0; + for line in lines { + let line_width = line.width(); + if line_width == 0 { + count = count.saturating_add(1); + } else { + // ceil(line_width / w) + let wrapped = line_width.div_ceil(w) as u16; + count = count.saturating_add(wrapped); + } + } + count +} + +fn render_banner( + lines: &mut Vec>, + banner: &crate::tui::state::BannerData, + width: u16, +) { + let tiger = [ + r" /\_/\ ", + r" / o o \ ", + r"=\ ^ /=", + r" )---( ", + r" /| |\ ", + r"(_| |_)", + ]; + let tiger_width = 9; + + // Pick a cute welcome message (short enough to fit the left pane) + let greetings = [ + "Welcome back!", + "Let's go!", + "Ready to code!", + "Let's build!", + ]; + // Use a simple deterministic pick based on version length + let greeting = greetings[banner.version.len() % greetings.len()]; + + // Full terminal width + let box_width = (width as usize).max(40); + let border_style = Style::default().fg(Color::DarkGray); + + // Left panel: tiger centered + greeting + // Right panel: title + info + help + // Layout: │ tiger │ + // Left panel: snug fit for tiger + greeting with comfortable padding + let left_width = (tiger_width + 14).min(box_width * 2 / 5); // ~23 chars, capped at 2/5 + let right_width = box_width.saturating_sub(left_width + 3); // 3 = "│" + "│" + "│" + + // Build right-side info rows + let max_ws_len = right_width.saturating_sub(13); // "Workspace: " = 11 + 2 padding + let mut right_rows: Vec>> = Vec::new(); + + // Title row + right_rows.push(vec![Span::styled( + format!("Tycode v{}", banner.version), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )]); + + // Blank separator + right_rows.push(vec![]); + + // Info fields + if let Some(ref provider) = banner.provider { + right_rows.push(vec![ + Span::styled("Provider: ".to_string(), Style::default().fg(Color::DarkGray)), + Span::styled(provider.clone(), Style::default().fg(Color::Green)), + ]); + } + if let Some(ref model) = banner.model { + right_rows.push(vec![ + Span::styled("Model: ".to_string(), Style::default().fg(Color::DarkGray)), + Span::styled(model.clone(), Style::default().fg(Color::Cyan)), + ]); + } + right_rows.push(vec![ + Span::styled("Agent: ".to_string(), Style::default().fg(Color::DarkGray)), + Span::styled(banner.agent.clone(), Style::default().fg(Color::Yellow)), + ]); + right_rows.push(vec![ + Span::styled("Workspace: ".to_string(), Style::default().fg(Color::DarkGray)), + Span::styled( + shorten_path(&banner.workspace, max_ws_len), + Style::default().fg(Color::White), + ), + ]); + + let memory_text = if banner.memory_enabled { + format!("enabled ({} recent)", banner.memory_count) + } else { + "disabled".to_string() + }; + let memory_color = if banner.memory_enabled { + Color::Green + } else { + Color::DarkGray + }; + right_rows.push(vec![ + Span::styled("Memory: ".to_string(), Style::default().fg(Color::DarkGray)), + Span::styled(memory_text, Style::default().fg(memory_color)), + ]); + + // Blank row before help + right_rows.push(vec![]); + + // Help commands + right_rows.push(vec![ + Span::styled("/help ".to_string(), Style::default().fg(Color::White)), + Span::styled("commands ".to_string(), Style::default().fg(Color::DarkGray)), + Span::styled("/settings ".to_string(), Style::default().fg(Color::White)), + Span::styled("config ".to_string(), Style::default().fg(Color::DarkGray)), + Span::styled("/quit ".to_string(), Style::default().fg(Color::White)), + Span::styled("exit".to_string(), Style::default().fg(Color::DarkGray)), + ]); + + // Calculate total content rows + // Left side: 1 blank + 6 tiger + 1 blank + 1 greeting + 1 blank = 10 + // Right side: right_rows.len() + let left_content_rows = tiger.len() + 3; // top pad + tiger + blank + greeting + let total_rows = left_content_rows.max(right_rows.len()); + + // Where the tiger starts vertically (centered in left panel) + let tiger_start = (total_rows.saturating_sub(tiger.len() + 2)) / 2; // +2 for blank+greeting + let greeting_row = tiger_start + tiger.len() + 1; // blank line then greeting + + // Top border: ╭───┬───╮ + let left_border: String = "─".repeat(left_width); + let right_border: String = "─".repeat(right_width); + lines.push(Line::from(vec![ + Span::styled("╭".to_string(), border_style), + Span::styled(left_border.clone(), border_style), + Span::styled("┬".to_string(), border_style), + Span::styled(right_border.clone(), border_style), + Span::styled("╮".to_string(), border_style), + ])); + + // Content rows + for row in 0..total_rows { + let mut spans: Vec> = Vec::new(); + + // Left border + spans.push(Span::styled("│".to_string(), border_style)); + + // Left panel content + if row >= tiger_start && row < tiger_start + tiger.len() { + // Tiger row - center it in the left panel + let tiger_idx = row - tiger_start; + let tiger_text = tiger[tiger_idx]; + let pad_total = left_width.saturating_sub(tiger_width); + let pad_left = pad_total / 2; + let pad_right = pad_total - pad_left; + spans.push(Span::raw(" ".repeat(pad_left))); + spans.push(Span::styled(tiger_text.to_string(), Style::default().fg(Color::Yellow))); + spans.push(Span::raw(" ".repeat(pad_right))); + } else if row == greeting_row { + // Greeting row - center it + let pad_total = left_width.saturating_sub(greeting.len()); + let pad_left = pad_total / 2; + let pad_right = pad_total - pad_left; + spans.push(Span::raw(" ".repeat(pad_left))); + spans.push(Span::styled( + greeting.to_string(), + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::raw(" ".repeat(pad_right))); + } else { + // Empty row + spans.push(Span::raw(" ".repeat(left_width))); + } + + // Vertical divider + spans.push(Span::styled("│".to_string(), border_style)); + + // Right panel content + if row < right_rows.len() && !right_rows[row].is_empty() { + spans.push(Span::raw(" ".to_string())); // left padding + let content_used: usize = right_rows[row].iter().map(|s| s.width()).sum(); + for span in &right_rows[row] { + spans.push(span.clone()); + } + let pad = right_width.saturating_sub(content_used + 1); // -1 for left space + spans.push(Span::raw(" ".repeat(pad))); + } else { + spans.push(Span::raw(" ".repeat(right_width))); + } + + // Right border + spans.push(Span::styled("│".to_string(), border_style)); + + lines.push(Line::from(spans)); + } + + // Bottom border: ╰───┴───╯ + lines.push(Line::from(vec![ + Span::styled("╰".to_string(), border_style), + Span::styled(left_border, border_style), + Span::styled("┴".to_string(), border_style), + Span::styled(right_border, border_style), + Span::styled("╯".to_string(), border_style), + ])); + + // Blank line after the box before chat entries + lines.push(Line::from("")); +} + +fn render_entry(lines: &mut Vec>, entry: &ChatEntry) { + match entry { + ChatEntry::UserMessage { content } => { + // Render each line of multi-line user messages + for (i, line) in content.lines().enumerate() { + let prefix = if i == 0 { "> " } else { " " }; + lines.push(Line::from(vec![ + Span::styled(prefix.to_string(), Style::default().fg(Color::Green)), + Span::styled(line.to_string(), Style::default().fg(Color::White)), + ])); + } + lines.push(Line::from("")); + } + + ChatEntry::AssistantMessage { + agent, + model, + content, + token_usage, + } => { + let mut header: Vec> = vec![ + Span::styled(format!("[{agent}]"), Style::default().fg(Color::Green)), + Span::styled( + format!(" ({model}) "), + Style::default().fg(Color::DarkGray), + ), + ]; + if let Some(usage) = token_usage { + let input = usage.input_tokens + usage.cache_creation_input_tokens.unwrap_or(0); + let output = usage.output_tokens + usage.reasoning_tokens.unwrap_or(0); + header.push(Span::styled( + format!("({input}/{output})"), + Style::default().fg(Color::DarkGray), + )); + } + lines.push(Line::from(header)); + + for line in content.lines() { + lines.push(Line::from(Span::raw(line.to_string()))); + } + lines.push(Line::from("")); + } + + ChatEntry::StreamingMessage { + agent, + model, + content, + } => { + lines.push(Line::from(vec![ + Span::styled(format!("[{agent}]"), Style::default().fg(Color::Green)), + Span::styled( + format!(" ({model}) "), + Style::default().fg(Color::DarkGray), + ), + ])); + for line in content.lines() { + lines.push(Line::from(Span::raw(line.to_string()))); + } + // Blinking cursor at the end of streaming + let cursor = Span::styled( + "\u{258c}".to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::SLOW_BLINK), + ); + if content.is_empty() || content.ends_with('\n') { + lines.push(Line::from(cursor)); + } else if let Some(last) = lines.last_mut() { + let mut spans: Vec> = last.spans.to_vec(); + spans.push(cursor); + *last = Line::from(spans); + } + } + + ChatEntry::SystemMessage { content } => { + lines.push(Line::from(vec![ + Span::styled("[system] ".to_string(), Style::default().fg(Color::Yellow)), + Span::styled(content.clone(), Style::default().fg(Color::White)), + ])); + } + + ChatEntry::WarningMessage { content } => { + lines.push(Line::from(vec![ + Span::styled( + "[warning] ".to_string(), + Style::default().fg(Color::Yellow), + ), + Span::styled(content.clone(), Style::default().fg(Color::Yellow)), + ])); + } + + ChatEntry::ErrorMessage { content } => { + lines.push(Line::from(vec![ + Span::styled("[error] ".to_string(), Style::default().fg(Color::Red)), + Span::styled(content.clone(), Style::default().fg(Color::Red)), + ])); + } + + ChatEntry::ToolRequest { summary, .. } => { + lines.push(Line::from(vec![ + Span::styled(" -> ".to_string(), Style::default().fg(Color::Cyan)), + Span::styled(summary.clone(), Style::default().fg(Color::DarkGray)), + ])); + } + + ChatEntry::ToolResult { + success, summary, .. + } => { + let (icon, color) = if *success { + (" \u{2713} ".to_string(), Color::Green) + } else { + (" \u{2717} ".to_string(), Color::Red) + }; + lines.push(Line::from(vec![ + Span::styled(icon, Style::default().fg(color)), + Span::styled(summary.clone(), Style::default().fg(Color::DarkGray)), + ])); + } + + ChatEntry::TaskUpdate { task_list } => { + if !task_list.tasks.is_empty() { + lines.push(Line::from(Span::styled( + format!(" Tasks: {}", task_list.title), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ))); + for task in &task_list.tasks { + let (icon, color) = match task.status { + TaskStatus::Completed => ("\u{2713}", Color::Green), + TaskStatus::InProgress => ("\u{25ce}", Color::Yellow), + TaskStatus::Pending => ("\u{25cb}", Color::DarkGray), + TaskStatus::Failed => ("\u{2717}", Color::Red), + }; + lines.push(Line::from(vec![ + Span::styled(format!(" {icon} "), Style::default().fg(color)), + Span::styled( + task.description.clone(), + Style::default().fg(Color::White), + ), + ])); + } + } + } + } +} + +fn shorten_path(path: &str, max_len: usize) -> String { + let home = std::env::var("HOME").unwrap_or_default(); + let path = if !home.is_empty() && path.starts_with(&home) { + format!("~{}", &path[home.len()..]) + } else { + path.to_string() + }; + + if path.len() <= max_len { + path + } else { + format!("...{}", &path[path.len().saturating_sub(max_len - 3)..]) + } +} diff --git a/tycode-cli/src/tui/widgets/input_area.rs b/tycode-cli/src/tui/widgets/input_area.rs new file mode 100644 index 0000000..92270aa --- /dev/null +++ b/tycode-cli/src/tui/widgets/input_area.rs @@ -0,0 +1,29 @@ +use ratatui::{ + layout::Rect, + style::{Color, Style}, + symbols::border, + widgets::{Block, Borders}, + Frame, +}; +use tui_textarea::TextArea; + +pub fn render(frame: &mut Frame, area: Rect, textarea: &TextArea) { + // Top and bottom borders only (no left/right) + let border_set = border::Set { + top_left: "─", + top_right: "─", + bottom_left: "─", + bottom_right: "─", + ..border::PLAIN + }; + + let block = Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .border_set(border_set) + .border_style(Style::default().fg(Color::DarkGray)); + + // Render the textarea with the horizontal-only borders + let inner = block.inner(area); + frame.render_widget(block, area); + frame.render_widget(textarea, inner); +} diff --git a/tycode-cli/src/tui/widgets/mod.rs b/tycode-cli/src/tui/widgets/mod.rs new file mode 100644 index 0000000..5ecd2c4 --- /dev/null +++ b/tycode-cli/src/tui/widgets/mod.rs @@ -0,0 +1,3 @@ +pub mod chat_panel; +pub mod input_area; +pub mod status_bar; diff --git a/tycode-cli/src/tui/widgets/status_bar.rs b/tycode-cli/src/tui/widgets/status_bar.rs new file mode 100644 index 0000000..758a000 --- /dev/null +++ b/tycode-cli/src/tui/widgets/status_bar.rs @@ -0,0 +1,65 @@ +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use crate::tui::state::TuiState; + +const SPINNER_CHARS: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +pub fn render(frame: &mut Frame, area: Rect, state: &TuiState) { + let status = if state.is_thinking { + let spinner = SPINNER_CHARS[state.spinner_frame % SPINNER_CHARS.len()]; + let text = if state.thinking_text.is_empty() { + "Thinking...".to_string() + } else { + state.thinking_text.clone() + }; + format!("{spinner} {text}") + } else if state.awaiting_response { + "Processing...".to_string() + } else { + "Ready".to_string() + }; + + let sep = Span::styled(" | ", Style::default().fg(Color::DarkGray)); + + let mut parts: Vec> = vec![ + Span::styled(" ", Style::default()), + Span::styled(state.current_agent.clone(), Style::default().fg(Color::Yellow)), + sep.clone(), + Span::styled(state.current_model.clone(), Style::default().fg(Color::Cyan)), + ]; + + // Only show token usage once there's actual usage + if state.total_input_tokens > 0 || state.total_output_tokens > 0 { + let input_str = format_token_count(state.total_input_tokens); + let output_str = format_token_count(state.total_output_tokens); + parts.push(sep.clone()); + parts.push(Span::styled( + format!("{input_str} in / {output_str} out"), + Style::default().fg(Color::White), + )); + } + + parts.push(sep); + parts.push(Span::styled(status, Style::default().fg(Color::Green))); + + let bar = Paragraph::new(Line::from(parts)) + .style(Style::default().bg(Color::Rgb(30, 30, 30))); + + frame.render_widget(bar, area); +} + +fn format_token_count(tokens: u32) -> String { + if tokens >= 1_000_000 { + format!("{:.1}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.1}k", tokens as f64 / 1_000.0) + } else { + format!("{tokens}") + } +}