2891 lines
105 KiB
Rust
2891 lines
105 KiB
Rust
//! TUI event loop and rendering logic for `DeepSeek` CLI.
|
|
|
|
use std::fmt::Write;
|
|
use std::io::{self, Stdout};
|
|
use std::path::PathBuf;
|
|
use std::time::Instant;
|
|
|
|
use anyhow::Result;
|
|
use chrono::Local;
|
|
use crossterm::{
|
|
event::{
|
|
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
|
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
|
|
MouseEventKind,
|
|
},
|
|
execute,
|
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
|
};
|
|
use ratatui::{
|
|
Frame, Terminal,
|
|
backend::CrosstermBackend,
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style, Stylize},
|
|
text::{Line, Span},
|
|
widgets::{Block, Paragraph, Wrap},
|
|
};
|
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
|
|
|
use crate::commands;
|
|
use crate::compaction::CompactionConfig;
|
|
use crate::config::Config;
|
|
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
|
|
use crate::core::events::Event as EngineEvent;
|
|
use crate::core::ops::Op;
|
|
use crate::hooks::HookEvent;
|
|
use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model};
|
|
use crate::palette;
|
|
use crate::prompts;
|
|
use crate::session_manager::{
|
|
SavedSession, SessionManager, create_saved_session_with_mode, update_session,
|
|
};
|
|
use crate::tools::ReviewOutput;
|
|
use crate::tools::spec::{ToolError, ToolResult};
|
|
use crate::tools::subagent::{SubAgentResult, SubAgentStatus};
|
|
use crate::tui::event_broker::EventBroker;
|
|
use crate::tui::onboarding;
|
|
use crate::tui::pager::PagerView;
|
|
use crate::tui::paste_burst::CharDecision;
|
|
use crate::tui::scrolling::{ScrollDirection, TranscriptScroll};
|
|
use crate::tui::selection::TranscriptSelectionPoint;
|
|
use crate::tui::session_picker::SessionPickerView;
|
|
use crate::tui::user_input::UserInputView;
|
|
use crate::utils::estimate_message_chars;
|
|
|
|
use super::app::{App, AppAction, AppMode, OnboardingState, QueuedMessage, TuiOptions};
|
|
use super::approval::{
|
|
ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
|
|
};
|
|
use super::history::{
|
|
DiffPreviewCell, ExecCell, ExecSource, ExploringCell, ExploringEntry, GenericToolCell,
|
|
HistoryCell, McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell,
|
|
ToolStatus, ViewImageCell, WebSearchCell, history_cells_from_message, summarize_mcp_output,
|
|
summarize_tool_args, summarize_tool_output,
|
|
};
|
|
use super::views::{HelpView, ModalKind, ViewEvent};
|
|
use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable};
|
|
|
|
// === Constants ===
|
|
|
|
const MAX_QUEUED_PREVIEW: usize = 3;
|
|
|
|
/// Run the interactive TUI event loop.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```ignore
|
|
/// # use crate::config::Config;
|
|
/// # use crate::tui::TuiOptions;
|
|
/// # async fn example(config: &Config, options: TuiOptions) -> anyhow::Result<()> {
|
|
/// crate::tui::run_tui(config, options).await
|
|
/// # }
|
|
/// ```
|
|
pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
|
|
let use_alt_screen = options.use_alt_screen;
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
if use_alt_screen {
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
}
|
|
execute!(stdout, EnableBracketedPaste, EnableMouseCapture)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
let event_broker = EventBroker::new();
|
|
|
|
let mut app = App::new(options.clone(), config);
|
|
|
|
// Load existing session if resuming
|
|
if let Some(ref session_id) = options.resume_session_id
|
|
&& let Ok(manager) = SessionManager::default_location()
|
|
{
|
|
// Try to load by prefix or full ID
|
|
let load_result: std::io::Result<Option<crate::session_manager::SavedSession>> =
|
|
if session_id == "latest" {
|
|
// Special case: resume the most recent session
|
|
match manager.get_latest_session() {
|
|
Ok(Some(meta)) => manager.load_session(&meta.id).map(Some),
|
|
Ok(None) => Ok(None),
|
|
Err(e) => Err(e),
|
|
}
|
|
} else {
|
|
manager.load_session_by_prefix(session_id).map(Some)
|
|
};
|
|
|
|
match load_result {
|
|
Ok(Some(saved)) => {
|
|
app.api_messages.clone_from(&saved.messages);
|
|
app.model.clone_from(&saved.metadata.model);
|
|
app.workspace.clone_from(&saved.metadata.workspace);
|
|
app.current_session_id = Some(saved.metadata.id.clone());
|
|
app.total_tokens = u32::try_from(saved.metadata.total_tokens).unwrap_or(u32::MAX);
|
|
app.total_conversation_tokens = app.total_tokens;
|
|
if let Some(prompt) = saved.system_prompt {
|
|
app.system_prompt = Some(SystemPrompt::Text(prompt));
|
|
}
|
|
// Convert saved messages to HistoryCell format for display
|
|
app.history.clear();
|
|
app.history.push(HistoryCell::System {
|
|
content: format!(
|
|
"Resumed session: {} ({})",
|
|
saved.metadata.title,
|
|
&saved.metadata.id[..8]
|
|
),
|
|
});
|
|
|
|
for msg in &saved.messages {
|
|
app.history.extend(history_cells_from_message(msg));
|
|
}
|
|
app.mark_history_updated();
|
|
app.status_message = Some(format!("Resumed session: {}", &saved.metadata.id[..8]));
|
|
}
|
|
Ok(None) => {
|
|
app.status_message = Some("No sessions found to resume".to_string());
|
|
}
|
|
Err(e) => {
|
|
app.status_message = Some(format!("Failed to load session: {e}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut compaction = CompactionConfig::default();
|
|
compaction.enabled = app.auto_compact;
|
|
compaction.token_threshold = app.compact_threshold;
|
|
compaction.model = app.model.clone();
|
|
|
|
// Create the Engine with configuration from TuiOptions
|
|
let engine_config = EngineConfig {
|
|
model: app.model.clone(),
|
|
workspace: app.workspace.clone(),
|
|
allow_shell: app.allow_shell,
|
|
trust_mode: options.yolo,
|
|
notes_path: config.notes_path(),
|
|
mcp_config_path: config.mcp_config_path(),
|
|
max_steps: 100,
|
|
max_subagents: app.max_subagents,
|
|
features: config.features(),
|
|
compaction,
|
|
todos: app.todos.clone(),
|
|
plan_state: app.plan_state.clone(),
|
|
};
|
|
|
|
// Spawn the Engine - it will handle all API communication
|
|
let engine_handle = spawn_engine(engine_config, config);
|
|
|
|
if !app.api_messages.is_empty() {
|
|
let _ = engine_handle
|
|
.send(Op::SyncSession {
|
|
messages: app.api_messages.clone(),
|
|
system_prompt: app.system_prompt.clone(),
|
|
model: app.model.clone(),
|
|
workspace: app.workspace.clone(),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
// Fire session start hook
|
|
{
|
|
let context = app.base_hook_context();
|
|
let _ = app.execute_hooks(HookEvent::SessionStart, &context);
|
|
}
|
|
|
|
let result = run_event_loop(
|
|
&mut terminal,
|
|
&mut app,
|
|
config,
|
|
engine_handle,
|
|
&event_broker,
|
|
)
|
|
.await;
|
|
|
|
// Fire session end hook
|
|
{
|
|
let context = app.base_hook_context();
|
|
let _ = app.execute_hooks(HookEvent::SessionEnd, &context);
|
|
}
|
|
|
|
disable_raw_mode()?;
|
|
if use_alt_screen {
|
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
}
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
DisableBracketedPaste,
|
|
DisableMouseCapture
|
|
)?;
|
|
terminal.show_cursor()?;
|
|
|
|
result
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn run_event_loop(
|
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
|
app: &mut App,
|
|
_config: &Config,
|
|
engine_handle: EngineHandle,
|
|
event_broker: &EventBroker,
|
|
) -> Result<()> {
|
|
// Track streaming state
|
|
let mut current_streaming_text = String::new();
|
|
|
|
loop {
|
|
// First, poll for engine events (non-blocking)
|
|
let mut queued_to_send: Option<QueuedMessage> = None;
|
|
{
|
|
let mut rx = engine_handle.rx_event.write().await;
|
|
while let Ok(event) = rx.try_recv() {
|
|
match event {
|
|
EngineEvent::MessageStarted { .. } => {
|
|
current_streaming_text.clear();
|
|
app.streaming_message_index = None;
|
|
}
|
|
EngineEvent::MessageDelta { content, .. } => {
|
|
current_streaming_text.push_str(&content);
|
|
let index = if let Some(index) = app.streaming_message_index {
|
|
index
|
|
} else {
|
|
app.add_message(HistoryCell::Assistant {
|
|
content: String::new(),
|
|
streaming: true,
|
|
});
|
|
let index = app.history.len().saturating_sub(1);
|
|
app.streaming_message_index = Some(index);
|
|
index
|
|
};
|
|
|
|
if let Some(cell) = app.history.get_mut(index) {
|
|
if let HistoryCell::Assistant { content, .. } = cell {
|
|
content.clone_from(¤t_streaming_text);
|
|
}
|
|
app.mark_history_updated();
|
|
}
|
|
}
|
|
EngineEvent::MessageComplete { .. } => {
|
|
if let Some(index) = app.streaming_message_index.take()
|
|
&& let Some(HistoryCell::Assistant { streaming, .. }) =
|
|
app.history.get_mut(index)
|
|
{
|
|
*streaming = false;
|
|
app.mark_history_updated();
|
|
}
|
|
|
|
if !current_streaming_text.is_empty()
|
|
|| app.last_reasoning.is_some()
|
|
|| !app.pending_tool_uses.is_empty()
|
|
{
|
|
let mut blocks = Vec::new();
|
|
if let Some(thinking) = app.last_reasoning.take() {
|
|
blocks.push(ContentBlock::Thinking { thinking });
|
|
}
|
|
if !current_streaming_text.is_empty() {
|
|
blocks.push(ContentBlock::Text {
|
|
text: current_streaming_text.clone(),
|
|
cache_control: None,
|
|
});
|
|
}
|
|
for (id, name, input) in app.pending_tool_uses.drain(..) {
|
|
blocks.push(ContentBlock::ToolUse { id, name, input });
|
|
}
|
|
if !blocks.is_empty() {
|
|
app.api_messages.push(Message {
|
|
role: "assistant".to_string(),
|
|
content: blocks,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
EngineEvent::ThinkingStarted { .. } => {
|
|
app.reasoning_buffer.clear();
|
|
app.reasoning_header = None;
|
|
|
|
app.add_message(HistoryCell::Thinking {
|
|
content: String::new(),
|
|
streaming: true,
|
|
});
|
|
app.streaming_message_index = Some(app.history.len().saturating_sub(1));
|
|
}
|
|
EngineEvent::ThinkingDelta { content, .. } => {
|
|
app.reasoning_buffer.push_str(&content);
|
|
if app.reasoning_header.is_none() {
|
|
app.reasoning_header = extract_reasoning_header(&app.reasoning_buffer);
|
|
}
|
|
|
|
if let Some(index) = app.streaming_message_index {
|
|
if let Some(HistoryCell::Thinking { content: c, .. }) =
|
|
app.history.get_mut(index)
|
|
{
|
|
c.push_str(&content);
|
|
}
|
|
}
|
|
}
|
|
EngineEvent::ThinkingComplete { .. } => {
|
|
if let Some(index) = app.streaming_message_index.take() {
|
|
if let Some(HistoryCell::Thinking { streaming, .. }) =
|
|
app.history.get_mut(index)
|
|
{
|
|
*streaming = false;
|
|
}
|
|
}
|
|
|
|
if !app.reasoning_buffer.is_empty() {
|
|
app.last_reasoning = Some(app.reasoning_buffer.clone());
|
|
}
|
|
app.reasoning_buffer.clear();
|
|
}
|
|
EngineEvent::ToolCallStarted { id, name, input } => {
|
|
app.pending_tool_uses
|
|
.push((id.clone(), name.clone(), input.clone()));
|
|
handle_tool_call_started(app, &id, &name, &input);
|
|
}
|
|
EngineEvent::ToolCallComplete { id, name, result } => {
|
|
if name == "update_plan" {
|
|
app.plan_tool_used_in_turn = true;
|
|
}
|
|
let tool_content = match &result {
|
|
Ok(output) => output.content.clone(),
|
|
Err(err) => format!("Error: {err}"),
|
|
};
|
|
app.api_messages.push(Message {
|
|
role: "user".to_string(),
|
|
content: vec![ContentBlock::ToolResult {
|
|
tool_use_id: id.clone(),
|
|
content: tool_content,
|
|
}],
|
|
});
|
|
handle_tool_call_complete(app, &id, &name, &result);
|
|
}
|
|
EngineEvent::TurnStarted => {
|
|
app.is_loading = true;
|
|
current_streaming_text.clear();
|
|
app.turn_started_at = Some(Instant::now());
|
|
app.reasoning_buffer.clear();
|
|
app.reasoning_header = None;
|
|
app.last_reasoning = None;
|
|
app.pending_tool_uses.clear();
|
|
app.plan_tool_used_in_turn = false;
|
|
}
|
|
EngineEvent::TurnComplete { usage } => {
|
|
app.is_loading = false;
|
|
app.turn_started_at = None;
|
|
let turn_tokens = usage.input_tokens + usage.output_tokens;
|
|
app.total_tokens = app.total_tokens.saturating_add(turn_tokens);
|
|
app.total_conversation_tokens =
|
|
app.total_conversation_tokens.saturating_add(turn_tokens);
|
|
app.last_prompt_tokens = Some(usage.input_tokens);
|
|
app.last_completion_tokens = Some(usage.output_tokens);
|
|
|
|
// Auto-save session after each turn
|
|
if let Ok(manager) = SessionManager::default_location() {
|
|
let session = if let Some(ref existing_id) = app.current_session_id {
|
|
// Update existing session
|
|
if let Ok(existing) = manager.load_session(existing_id) {
|
|
let mut updated = update_session(
|
|
existing,
|
|
&app.api_messages,
|
|
u64::from(app.total_tokens),
|
|
app.system_prompt.as_ref(),
|
|
);
|
|
updated.metadata.mode = Some(app.mode.label().to_string());
|
|
updated
|
|
} else {
|
|
// Session was deleted, create new
|
|
create_saved_session_with_mode(
|
|
&app.api_messages,
|
|
&app.model,
|
|
&app.workspace,
|
|
u64::from(app.total_tokens),
|
|
app.system_prompt.as_ref(),
|
|
Some(app.mode.label()),
|
|
)
|
|
}
|
|
} else {
|
|
// Create new session
|
|
create_saved_session_with_mode(
|
|
&app.api_messages,
|
|
&app.model,
|
|
&app.workspace,
|
|
u64::from(app.total_tokens),
|
|
app.system_prompt.as_ref(),
|
|
Some(app.mode.label()),
|
|
)
|
|
};
|
|
|
|
if let Err(e) = manager.save_session(&session) {
|
|
eprintln!("Failed to save session: {e}");
|
|
} else {
|
|
app.current_session_id = Some(session.metadata.id.clone());
|
|
}
|
|
}
|
|
|
|
if app.mode == AppMode::Plan
|
|
&& app.plan_tool_used_in_turn
|
|
&& !app.plan_prompt_pending
|
|
&& app.queued_message_count() == 0
|
|
&& app.queued_draft.is_none()
|
|
{
|
|
app.plan_prompt_pending = true;
|
|
app.add_message(HistoryCell::System {
|
|
content: plan_next_step_prompt(),
|
|
});
|
|
}
|
|
app.plan_tool_used_in_turn = false;
|
|
|
|
if queued_to_send.is_none() {
|
|
queued_to_send = app.pop_queued_message();
|
|
}
|
|
}
|
|
EngineEvent::Error { message, .. } => {
|
|
app.add_message(HistoryCell::System {
|
|
content: format!("Error: {message}"),
|
|
});
|
|
app.is_loading = false;
|
|
}
|
|
EngineEvent::Status { message } => {
|
|
app.status_message = Some(message);
|
|
}
|
|
EngineEvent::PauseEvents => {
|
|
if !event_broker.is_paused() {
|
|
pause_terminal(terminal, app.use_alt_screen)?;
|
|
event_broker.pause_events();
|
|
}
|
|
}
|
|
EngineEvent::ResumeEvents => {
|
|
if event_broker.is_paused() {
|
|
resume_terminal(terminal, app.use_alt_screen)?;
|
|
event_broker.resume_events();
|
|
}
|
|
}
|
|
EngineEvent::AgentSpawned { id, prompt } => {
|
|
app.add_message(HistoryCell::System {
|
|
content: format!(
|
|
"Sub-agent {id} spawned: {}",
|
|
summarize_tool_output(&prompt)
|
|
),
|
|
});
|
|
if app.view_stack.top_kind() == Some(ModalKind::SubAgents) {
|
|
let _ = engine_handle.send(Op::ListSubAgents).await;
|
|
}
|
|
}
|
|
EngineEvent::AgentProgress { id, status } => {
|
|
app.status_message = Some(format!("Sub-agent {id}: {status}"));
|
|
}
|
|
EngineEvent::AgentComplete { id, result } => {
|
|
app.add_message(HistoryCell::System {
|
|
content: format!(
|
|
"Sub-agent {id} completed: {}",
|
|
summarize_tool_output(&result)
|
|
),
|
|
});
|
|
if app.view_stack.top_kind() == Some(ModalKind::SubAgents) {
|
|
let _ = engine_handle.send(Op::ListSubAgents).await;
|
|
}
|
|
}
|
|
EngineEvent::AgentList { agents } => {
|
|
app.subagent_cache = agents.clone();
|
|
if app.view_stack.update_subagents(&agents) {
|
|
app.status_message =
|
|
Some(format!("Sub-agents: {} total", agents.len()));
|
|
} else {
|
|
app.add_message(HistoryCell::System {
|
|
content: format_subagent_list(&agents),
|
|
});
|
|
}
|
|
}
|
|
EngineEvent::ApprovalRequired {
|
|
id,
|
|
tool_name,
|
|
description,
|
|
} => {
|
|
let session_approved = app.approval_session_approved.contains(&tool_name);
|
|
if session_approved || app.approval_mode == ApprovalMode::Auto {
|
|
let _ = engine_handle.approve_tool_call(id.clone()).await;
|
|
} else if app.approval_mode == ApprovalMode::Never {
|
|
let _ = engine_handle.deny_tool_call(id.clone()).await;
|
|
app.add_message(HistoryCell::System {
|
|
content: format!(
|
|
"Blocked tool '{tool_name}' (approval_mode=never)"
|
|
),
|
|
});
|
|
} else {
|
|
let tool_input = app
|
|
.pending_tool_uses
|
|
.iter()
|
|
.find(|(tool_id, _, _)| tool_id == &id)
|
|
.map(|(_, _, input)| input.clone())
|
|
.unwrap_or_else(|| serde_json::json!({}));
|
|
|
|
if tool_name == "apply_patch" {
|
|
maybe_add_patch_preview(app, &tool_input);
|
|
}
|
|
|
|
// Create approval request and show overlay
|
|
let request = ApprovalRequest::new(&id, &tool_name, &tool_input);
|
|
app.view_stack.push(ApprovalView::new(request));
|
|
app.add_message(HistoryCell::System {
|
|
content: format!(
|
|
"Approval required for tool '{tool_name}': {description}"
|
|
),
|
|
});
|
|
}
|
|
}
|
|
EngineEvent::UserInputRequired { id, request } => {
|
|
app.view_stack.push(UserInputView::new(id.clone(), request));
|
|
app.add_message(HistoryCell::System {
|
|
content: "User input requested".to_string(),
|
|
});
|
|
}
|
|
EngineEvent::ToolCallProgress { id, output } => {
|
|
app.status_message =
|
|
Some(format!("Tool {id}: {}", summarize_tool_output(&output)));
|
|
}
|
|
EngineEvent::ElevationRequired {
|
|
tool_id,
|
|
tool_name,
|
|
command,
|
|
denial_reason,
|
|
blocked_network,
|
|
blocked_write,
|
|
} => {
|
|
// In YOLO mode, auto-elevate to full access
|
|
if app.approval_mode == ApprovalMode::Auto {
|
|
app.add_message(HistoryCell::System {
|
|
content: format!(
|
|
"Sandbox denied {tool_name}: {denial_reason} - auto-elevating to full access"
|
|
),
|
|
});
|
|
// Auto-elevate to full access (no sandbox)
|
|
let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
|
|
let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
|
|
} else {
|
|
// Show elevation dialog
|
|
let request = ElevationRequest::for_shell(
|
|
&tool_id,
|
|
command.as_deref().unwrap_or(&tool_name),
|
|
&denial_reason,
|
|
blocked_network,
|
|
blocked_write,
|
|
);
|
|
app.view_stack.push(ElevationView::new(request));
|
|
app.add_message(HistoryCell::System {
|
|
content: format!("Sandbox blocked {tool_name}: {denial_reason}"),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(next) = queued_to_send {
|
|
dispatch_user_message(app, &engine_handle, next).await?;
|
|
}
|
|
|
|
if !app.view_stack.is_empty() {
|
|
let events = app.view_stack.tick();
|
|
handle_view_events(app, &engine_handle, events).await;
|
|
}
|
|
|
|
if event_broker.is_paused() {
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
continue;
|
|
}
|
|
|
|
app.flush_paste_burst_if_due(Instant::now());
|
|
|
|
terminal.draw(|f| render(f, app))?; // app is &mut
|
|
|
|
if event::poll(std::time::Duration::from_millis(50))? {
|
|
let evt = event::read()?;
|
|
|
|
// Handle bracketed paste events
|
|
if let Event::Paste(text) = &evt {
|
|
if app.onboarding == OnboardingState::ApiKey {
|
|
// Paste into API key input
|
|
app.insert_api_key_str(text);
|
|
} else {
|
|
// Paste into main input
|
|
if let Some(pending) = app.paste_burst.flush_before_modified_input() {
|
|
app.insert_str(&pending);
|
|
}
|
|
app.insert_paste_text(text);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if let Event::Resize(width, height) = evt {
|
|
terminal.clear()?;
|
|
app.handle_resize(width, height);
|
|
continue;
|
|
}
|
|
|
|
if let Event::Mouse(mouse) = evt {
|
|
handle_mouse_event(app, mouse);
|
|
continue;
|
|
}
|
|
|
|
let Event::Key(key) = evt else {
|
|
continue;
|
|
};
|
|
|
|
if key.kind != KeyEventKind::Press {
|
|
continue;
|
|
}
|
|
|
|
// Handle onboarding flow
|
|
if app.onboarding != OnboardingState::None {
|
|
let advance_onboarding = |app: &mut App| {
|
|
app.status_message = None;
|
|
if app.onboarding_needs_api_key {
|
|
app.onboarding = OnboardingState::ApiKey;
|
|
} else if !app.trust_mode && onboarding::needs_trust(&app.workspace) {
|
|
app.onboarding = OnboardingState::TrustDirectory;
|
|
} else {
|
|
app.onboarding = OnboardingState::Tips;
|
|
}
|
|
};
|
|
|
|
match key.code {
|
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
let _ = engine_handle.send(Op::Shutdown).await;
|
|
return Ok(());
|
|
}
|
|
KeyCode::Esc => {
|
|
if app.onboarding == OnboardingState::ApiKey {
|
|
app.onboarding = OnboardingState::Welcome;
|
|
app.api_key_input.clear();
|
|
app.api_key_cursor = 0;
|
|
app.status_message = None;
|
|
}
|
|
}
|
|
KeyCode::Enter => match app.onboarding {
|
|
OnboardingState::Welcome => {
|
|
advance_onboarding(app);
|
|
}
|
|
OnboardingState::ApiKey => match app.submit_api_key() {
|
|
Ok(_) => {
|
|
advance_onboarding(app);
|
|
}
|
|
Err(e) => {
|
|
app.status_message = Some(e.to_string());
|
|
}
|
|
},
|
|
OnboardingState::TrustDirectory => {}
|
|
OnboardingState::Tips => {
|
|
app.finish_onboarding();
|
|
}
|
|
OnboardingState::None => {}
|
|
},
|
|
KeyCode::Char('y') | KeyCode::Char('Y')
|
|
if app.onboarding == OnboardingState::TrustDirectory =>
|
|
{
|
|
match onboarding::mark_trusted(&app.workspace) {
|
|
Ok(_) => {
|
|
app.trust_mode = true;
|
|
app.status_message = None;
|
|
app.onboarding = OnboardingState::Tips;
|
|
}
|
|
Err(err) => {
|
|
app.status_message =
|
|
Some(format!("Failed to trust workspace: {err}"));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Char('N')
|
|
if app.onboarding == OnboardingState::TrustDirectory =>
|
|
{
|
|
app.status_message = None;
|
|
app.onboarding = OnboardingState::Tips;
|
|
}
|
|
KeyCode::Backspace if app.onboarding == OnboardingState::ApiKey => {
|
|
app.delete_api_key_char();
|
|
}
|
|
KeyCode::Char(c) if app.onboarding == OnboardingState::ApiKey => {
|
|
app.insert_api_key_char(c);
|
|
}
|
|
KeyCode::Char('v') | KeyCode::Char('V')
|
|
if is_paste_shortcut(&key) && app.onboarding == OnboardingState::ApiKey =>
|
|
{
|
|
// Cmd+V / Ctrl+V paste (bracketed paste handled above)
|
|
app.paste_api_key_from_clipboard();
|
|
}
|
|
_ => {}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if key.code == KeyCode::F(1) {
|
|
if app.view_stack.top_kind() == Some(ModalKind::Help) {
|
|
app.view_stack.pop();
|
|
} else {
|
|
app.view_stack.push(HelpView::new());
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if key.code == KeyCode::Char('/') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
if app.view_stack.top_kind() == Some(ModalKind::Help) {
|
|
app.view_stack.pop();
|
|
} else {
|
|
app.view_stack.push(HelpView::new());
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if !app.view_stack.is_empty() {
|
|
let events = app.view_stack.handle_key(key);
|
|
handle_view_events(app, &engine_handle, events).await;
|
|
continue;
|
|
}
|
|
|
|
let now = Instant::now();
|
|
app.flush_paste_burst_if_due(now);
|
|
|
|
let has_ctrl_alt_or_super = key.modifiers.contains(KeyModifiers::CONTROL)
|
|
|| key.modifiers.contains(KeyModifiers::ALT)
|
|
|| key.modifiers.contains(KeyModifiers::SUPER);
|
|
let is_plain_char = matches!(key.code, KeyCode::Char(_)) && !has_ctrl_alt_or_super;
|
|
let is_enter = matches!(key.code, KeyCode::Enter);
|
|
|
|
if !is_plain_char
|
|
&& !is_enter
|
|
&& let Some(pending) = app.paste_burst.flush_before_modified_input()
|
|
{
|
|
app.insert_str(&pending);
|
|
}
|
|
|
|
if (is_plain_char || is_enter) && handle_paste_burst_key(app, &key, now) {
|
|
continue;
|
|
}
|
|
|
|
// Global keybindings
|
|
match key.code {
|
|
KeyCode::Enter if app.input.is_empty() && app.transcript_selection.is_active() => {
|
|
if open_pager_for_selection(app) {
|
|
continue;
|
|
}
|
|
}
|
|
KeyCode::Char('l') if key.modifiers.is_empty() && app.input.is_empty() => {
|
|
if open_pager_for_last_message(app) {
|
|
continue;
|
|
}
|
|
}
|
|
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
app.view_stack.push(SessionPickerView::new());
|
|
continue;
|
|
}
|
|
KeyCode::Char('c') | KeyCode::Char('C')
|
|
if key.modifiers.contains(KeyModifiers::CONTROL)
|
|
&& app.transcript_selection.is_active() =>
|
|
{
|
|
copy_active_selection(app);
|
|
}
|
|
KeyCode::Char('c') | KeyCode::Char('C') if is_copy_shortcut(&key) => {
|
|
copy_active_selection(app);
|
|
}
|
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
// Cancel current request or exit
|
|
if app.is_loading {
|
|
engine_handle.cancel();
|
|
app.is_loading = false;
|
|
app.status_message = Some("Request cancelled".to_string());
|
|
} else {
|
|
let _ = engine_handle.send(Op::Shutdown).await;
|
|
return Ok(());
|
|
}
|
|
}
|
|
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
if app.input.is_empty() {
|
|
let _ = engine_handle.send(Op::Shutdown).await;
|
|
return Ok(());
|
|
}
|
|
}
|
|
KeyCode::Esc => {
|
|
if app.is_loading {
|
|
engine_handle.cancel();
|
|
app.is_loading = false;
|
|
app.status_message = Some("Request cancelled".to_string());
|
|
} else if !app.input.is_empty() {
|
|
app.clear_input();
|
|
} else {
|
|
app.set_mode(AppMode::Normal);
|
|
}
|
|
}
|
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
|
|
app.scroll_up(3);
|
|
}
|
|
KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => {
|
|
app.scroll_down(3);
|
|
}
|
|
KeyCode::PageUp => {
|
|
let page = app.last_transcript_visible.max(1);
|
|
app.scroll_up(page);
|
|
}
|
|
KeyCode::PageDown => {
|
|
let page = app.last_transcript_visible.max(1);
|
|
app.scroll_down(page);
|
|
}
|
|
KeyCode::Tab => {
|
|
app.cycle_mode();
|
|
}
|
|
// Input handling
|
|
KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
app.insert_char('\n');
|
|
}
|
|
KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => {
|
|
app.insert_char('\n');
|
|
}
|
|
KeyCode::Enter => {
|
|
if let Some(input) = app.submit_input() {
|
|
if handle_plan_choice(app, &engine_handle, &input).await? {
|
|
continue;
|
|
}
|
|
if input.starts_with('/') {
|
|
// Use the commands module for slash commands
|
|
let result = commands::execute(&input, app);
|
|
|
|
// Handle command result
|
|
if let Some(msg) = result.message {
|
|
app.add_message(HistoryCell::System { content: msg });
|
|
}
|
|
|
|
if let Some(action) = result.action {
|
|
match action {
|
|
AppAction::Quit => {
|
|
let _ = engine_handle.send(Op::Shutdown).await;
|
|
return Ok(());
|
|
}
|
|
AppAction::SaveSession(path) => {
|
|
app.status_message =
|
|
Some(format!("Session saved to {}", path.display()));
|
|
}
|
|
AppAction::LoadSession(path) => {
|
|
app.status_message =
|
|
Some(format!("Session loaded from {}", path.display()));
|
|
}
|
|
AppAction::SyncSession {
|
|
messages,
|
|
system_prompt,
|
|
model,
|
|
workspace,
|
|
} => {
|
|
let _ = engine_handle
|
|
.send(Op::SyncSession {
|
|
messages,
|
|
system_prompt,
|
|
model,
|
|
workspace,
|
|
})
|
|
.await;
|
|
}
|
|
AppAction::SendMessage(content) => {
|
|
let queued = build_queued_message(app, content);
|
|
dispatch_user_message(app, &engine_handle, queued).await?;
|
|
}
|
|
AppAction::ListSubAgents => {
|
|
let _ = engine_handle.send(Op::ListSubAgents).await;
|
|
}
|
|
AppAction::UpdateCompaction(compaction) => {
|
|
let _ = engine_handle
|
|
.send(Op::SetCompaction { config: compaction })
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Global @ file completion - works in any mode
|
|
if let Some(path) = input.trim().strip_prefix('@') {
|
|
let command = format!("/load @{path}");
|
|
let result = commands::execute(&command, app);
|
|
if let Some(msg) = result.message {
|
|
app.add_message(HistoryCell::System { content: msg });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let queued = if let Some(mut draft) = app.queued_draft.take() {
|
|
draft.display = input;
|
|
draft
|
|
} else {
|
|
build_queued_message(app, input)
|
|
};
|
|
if app.is_loading {
|
|
app.queue_message(queued);
|
|
app.status_message = Some(format!(
|
|
"Queued {} message(s) - /queue to view/edit",
|
|
app.queued_message_count()
|
|
));
|
|
} else {
|
|
dispatch_user_message(app, &engine_handle, queued).await?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.delete_char();
|
|
}
|
|
KeyCode::Delete => {
|
|
app.delete_char_forward();
|
|
}
|
|
KeyCode::Left => {
|
|
app.move_cursor_left();
|
|
}
|
|
KeyCode::Right => {
|
|
app.move_cursor_right();
|
|
}
|
|
KeyCode::Home if key.modifiers.is_empty() => {
|
|
if let Some(anchor) =
|
|
TranscriptScroll::anchor_for(app.transcript_cache.line_meta(), 0)
|
|
{
|
|
app.transcript_scroll = anchor;
|
|
}
|
|
}
|
|
KeyCode::End if key.modifiers.is_empty() => {
|
|
app.scroll_to_bottom();
|
|
}
|
|
KeyCode::Home | KeyCode::Char('a')
|
|
if key.modifiers.contains(KeyModifiers::CONTROL) =>
|
|
{
|
|
app.move_cursor_start();
|
|
}
|
|
KeyCode::End | KeyCode::Char('e')
|
|
if key.modifiers.contains(KeyModifiers::CONTROL) =>
|
|
{
|
|
app.move_cursor_end();
|
|
}
|
|
KeyCode::Up => {
|
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
app.history_up();
|
|
} else if should_scroll_with_arrows(app) {
|
|
app.scroll_up(1);
|
|
} else {
|
|
app.history_up();
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
app.history_down();
|
|
} else if should_scroll_with_arrows(app) {
|
|
app.scroll_down(1);
|
|
} else {
|
|
app.history_down();
|
|
}
|
|
}
|
|
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
app.clear_input();
|
|
}
|
|
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
let new_mode = match app.mode {
|
|
AppMode::Agent => AppMode::Normal,
|
|
_ => AppMode::Agent,
|
|
};
|
|
app.set_mode(new_mode);
|
|
}
|
|
KeyCode::Char('v') if is_paste_shortcut(&key) => {
|
|
app.paste_from_clipboard();
|
|
}
|
|
KeyCode::Char(c) => {
|
|
app.insert_char(c);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if !is_plain_char && !is_enter {
|
|
app.paste_burst.clear_window_after_non_char();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_paste_burst_key(app: &mut App, key: &KeyEvent, now: Instant) -> bool {
|
|
let has_ctrl_alt_or_super = key.modifiers.contains(KeyModifiers::CONTROL)
|
|
|| key.modifiers.contains(KeyModifiers::ALT)
|
|
|| key.modifiers.contains(KeyModifiers::SUPER);
|
|
|
|
match key.code {
|
|
KeyCode::Enter => {
|
|
if !in_command_context(app) && app.paste_burst.append_newline_if_active(now) {
|
|
return true;
|
|
}
|
|
if !in_command_context(app)
|
|
&& app.paste_burst.newline_should_insert_instead_of_submit(now)
|
|
{
|
|
app.insert_char('\n');
|
|
app.paste_burst.extend_window(now);
|
|
return true;
|
|
}
|
|
}
|
|
KeyCode::Char(c) if !has_ctrl_alt_or_super => {
|
|
if !c.is_ascii() {
|
|
if let Some(pending) = app.paste_burst.flush_before_modified_input() {
|
|
app.insert_str(&pending);
|
|
}
|
|
if app.paste_burst.try_append_char_if_active(c, now) {
|
|
return true;
|
|
}
|
|
if let Some(decision) = app.paste_burst.on_plain_char_no_hold(now) {
|
|
return handle_paste_burst_decision(app, decision, c, now);
|
|
}
|
|
app.insert_char(c);
|
|
return true;
|
|
}
|
|
|
|
let decision = app.paste_burst.on_plain_char(c, now);
|
|
return handle_paste_burst_decision(app, decision, c, now);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn handle_paste_burst_decision(
|
|
app: &mut App,
|
|
decision: CharDecision,
|
|
c: char,
|
|
now: Instant,
|
|
) -> bool {
|
|
match decision {
|
|
CharDecision::RetainFirstChar => true,
|
|
CharDecision::BeginBufferFromPending | CharDecision::BufferAppend => {
|
|
app.paste_burst.append_char_to_buffer(c, now);
|
|
true
|
|
}
|
|
CharDecision::BeginBuffer { retro_chars } => {
|
|
if apply_paste_burst_retro_capture(app, retro_chars as usize, c, now) {
|
|
return true;
|
|
}
|
|
app.insert_char(c);
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_paste_burst_retro_capture(
|
|
app: &mut App,
|
|
retro_chars: usize,
|
|
c: char,
|
|
now: Instant,
|
|
) -> bool {
|
|
let cursor_byte = app.cursor_byte_index();
|
|
let before = &app.input[..cursor_byte];
|
|
let Some(grab) = app
|
|
.paste_burst
|
|
.decide_begin_buffer(now, before, retro_chars)
|
|
else {
|
|
return false;
|
|
};
|
|
if !grab.grabbed.is_empty() {
|
|
app.input.replace_range(grab.start_byte..cursor_byte, "");
|
|
let removed = grab.grabbed.chars().count();
|
|
app.cursor_position = app.cursor_position.saturating_sub(removed);
|
|
}
|
|
app.paste_burst.append_char_to_buffer(c, now);
|
|
true
|
|
}
|
|
|
|
fn in_command_context(app: &App) -> bool {
|
|
app.input.starts_with('/')
|
|
}
|
|
|
|
fn build_queued_message(app: &mut App, input: String) -> QueuedMessage {
|
|
let skill_instruction = app.active_skill.take();
|
|
QueuedMessage::new(input, skill_instruction)
|
|
}
|
|
|
|
async fn dispatch_user_message(
|
|
app: &mut App,
|
|
engine_handle: &EngineHandle,
|
|
message: QueuedMessage,
|
|
) -> Result<()> {
|
|
// Set immediately to prevent double-dispatch before TurnStarted event arrives.
|
|
app.is_loading = true;
|
|
|
|
let content = message.content();
|
|
app.system_prompt = Some(prompts::system_prompt_for_mode_with_context(
|
|
app.mode,
|
|
&app.workspace,
|
|
None,
|
|
));
|
|
app.add_message(HistoryCell::User {
|
|
content: message.display.clone(),
|
|
});
|
|
app.api_messages.push(Message {
|
|
role: "user".to_string(),
|
|
content: vec![ContentBlock::Text {
|
|
text: content.clone(),
|
|
cache_control: None,
|
|
}],
|
|
});
|
|
|
|
engine_handle
|
|
.send(Op::SendMessage {
|
|
content,
|
|
mode: app.mode,
|
|
model: app.model.clone(),
|
|
allow_shell: app.allow_shell,
|
|
trust_mode: app.trust_mode,
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum PlanChoice {
|
|
ImplementAgent,
|
|
ImplementYolo,
|
|
RevisePlan,
|
|
ExitPlan,
|
|
}
|
|
|
|
fn plan_next_step_prompt() -> String {
|
|
[
|
|
"Plan ready. Choose next step:",
|
|
" 1) Implement in Agent mode (approvals on)",
|
|
" 2) Implement in YOLO mode (auto-approve)",
|
|
" 3) Revise the plan / ask follow-ups",
|
|
" 4) Exit Plan mode",
|
|
"",
|
|
"Type 1-4 and press Enter.",
|
|
]
|
|
.join("\n")
|
|
}
|
|
|
|
fn parse_plan_choice(input: &str) -> Option<PlanChoice> {
|
|
let trimmed = input.trim().to_lowercase();
|
|
let first = trimmed.chars().next()?;
|
|
match first {
|
|
'1' => return Some(PlanChoice::ImplementAgent),
|
|
'2' => return Some(PlanChoice::ImplementYolo),
|
|
'3' => return Some(PlanChoice::RevisePlan),
|
|
'4' => return Some(PlanChoice::ExitPlan),
|
|
_ => {}
|
|
}
|
|
|
|
match trimmed.as_str() {
|
|
"agent" | "a" => Some(PlanChoice::ImplementAgent),
|
|
"yolo" | "y" => Some(PlanChoice::ImplementYolo),
|
|
"revise" | "edit" | "plan" | "stay" => Some(PlanChoice::RevisePlan),
|
|
"normal" | "exit" | "cancel" | "back" => Some(PlanChoice::ExitPlan),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn handle_plan_choice(
|
|
app: &mut App,
|
|
engine_handle: &EngineHandle,
|
|
input: &str,
|
|
) -> Result<bool> {
|
|
if !app.plan_prompt_pending {
|
|
return Ok(false);
|
|
}
|
|
|
|
let choice = parse_plan_choice(input);
|
|
app.plan_prompt_pending = false;
|
|
|
|
let Some(choice) = choice else {
|
|
return Ok(false);
|
|
};
|
|
|
|
match choice {
|
|
PlanChoice::ImplementAgent => {
|
|
app.set_mode(AppMode::Agent);
|
|
app.add_message(HistoryCell::System {
|
|
content: "Plan approved. Switching to Agent mode and starting implementation."
|
|
.to_string(),
|
|
});
|
|
let followup = QueuedMessage::new("Proceed with the plan.".to_string(), None);
|
|
if app.is_loading {
|
|
app.queue_message(followup);
|
|
app.status_message = Some("Queued plan execution (agent mode).".to_string());
|
|
} else {
|
|
dispatch_user_message(app, engine_handle, followup).await?;
|
|
}
|
|
}
|
|
PlanChoice::ImplementYolo => {
|
|
app.set_mode(AppMode::Yolo);
|
|
app.add_message(HistoryCell::System {
|
|
content: "Plan approved. Switching to YOLO mode and starting implementation."
|
|
.to_string(),
|
|
});
|
|
let followup = QueuedMessage::new("Proceed with the plan.".to_string(), None);
|
|
if app.is_loading {
|
|
app.queue_message(followup);
|
|
app.status_message = Some("Queued plan execution (YOLO mode).".to_string());
|
|
} else {
|
|
dispatch_user_message(app, engine_handle, followup).await?;
|
|
}
|
|
}
|
|
PlanChoice::RevisePlan => {
|
|
let prompt = "Revise the plan: ";
|
|
app.input = prompt.to_string();
|
|
app.cursor_position = prompt.chars().count();
|
|
app.status_message = Some("Revise the plan and press Enter.".to_string());
|
|
}
|
|
PlanChoice::ExitPlan => {
|
|
app.set_mode(AppMode::Agent);
|
|
app.add_message(HistoryCell::System {
|
|
content: "Exited Plan mode. Switched to Agent mode.".to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
fn render(f: &mut Frame, app: &mut App) {
|
|
let size = f.area();
|
|
|
|
// Clear entire area with background color
|
|
let background = Block::default().style(Style::default().bg(app.ui_theme.header_bg));
|
|
f.render_widget(background, size);
|
|
|
|
// Show onboarding screen if needed
|
|
if app.onboarding != OnboardingState::None {
|
|
onboarding::render(f, size, app);
|
|
return;
|
|
}
|
|
|
|
let header_height = 1;
|
|
let footer_height = 1;
|
|
let queued_preview = app.queued_message_previews(MAX_QUEUED_PREVIEW);
|
|
let queued_lines = if queued_preview.is_empty() {
|
|
0
|
|
} else {
|
|
queued_preview.len() + 1
|
|
};
|
|
let editing_lines = usize::from(app.queued_draft.is_some());
|
|
let status_lines = usize::from(app.is_loading);
|
|
let status_height =
|
|
u16::try_from(status_lines + queued_lines + editing_lines).unwrap_or(u16::MAX);
|
|
let prompt = prompt_for_mode(app.mode);
|
|
let available_height = size
|
|
.height
|
|
.saturating_sub(header_height + footer_height + status_height);
|
|
let composer_height = {
|
|
let composer_widget = ComposerWidget::new(app, prompt, available_height);
|
|
composer_widget.desired_height(size.width)
|
|
};
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(header_height), // Header
|
|
Constraint::Min(1), // Chat area
|
|
Constraint::Length(status_height), // Status indicator
|
|
Constraint::Length(composer_height), // Composer
|
|
Constraint::Length(footer_height), // Footer
|
|
])
|
|
.split(size);
|
|
|
|
// Render header
|
|
{
|
|
let header_data =
|
|
HeaderData::new(app.mode, &app.model, app.is_loading, app.ui_theme.header_bg);
|
|
let header_widget = HeaderWidget::new(header_data);
|
|
let buf = f.buffer_mut();
|
|
header_widget.render(chunks[0], buf);
|
|
}
|
|
|
|
// Render chat
|
|
{
|
|
let chat_widget = ChatWidget::new(app, chunks[1]);
|
|
let buf = f.buffer_mut();
|
|
chat_widget.render(chunks[1], buf);
|
|
}
|
|
|
|
// Render status
|
|
if status_height > 0 {
|
|
render_status_indicator(f, chunks[2], app, &queued_preview);
|
|
}
|
|
|
|
// Render composer
|
|
let cursor_pos = {
|
|
let composer_widget = ComposerWidget::new(app, prompt, available_height);
|
|
let buf = f.buffer_mut();
|
|
composer_widget.render(chunks[3], buf);
|
|
composer_widget.cursor_pos(chunks[3])
|
|
};
|
|
if let Some(cursor_pos) = cursor_pos {
|
|
f.set_cursor_position(cursor_pos);
|
|
}
|
|
|
|
// Render footer
|
|
render_footer(f, chunks[4], app);
|
|
|
|
if !app.view_stack.is_empty() {
|
|
let buf = f.buffer_mut();
|
|
app.view_stack.render(size, buf);
|
|
}
|
|
}
|
|
|
|
async fn handle_view_events(app: &mut App, engine_handle: &EngineHandle, events: Vec<ViewEvent>) {
|
|
for event in events {
|
|
match event {
|
|
ViewEvent::ApprovalDecision {
|
|
tool_id,
|
|
tool_name,
|
|
decision,
|
|
timed_out,
|
|
} => {
|
|
if decision == ReviewDecision::ApprovedForSession {
|
|
app.approval_session_approved.insert(tool_name);
|
|
}
|
|
|
|
match decision {
|
|
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
|
|
let _ = engine_handle.approve_tool_call(tool_id).await;
|
|
}
|
|
ReviewDecision::Denied | ReviewDecision::Abort => {
|
|
let _ = engine_handle.deny_tool_call(tool_id).await;
|
|
}
|
|
}
|
|
|
|
if timed_out {
|
|
app.add_message(HistoryCell::System {
|
|
content: "Approval request timed out - denied".to_string(),
|
|
});
|
|
}
|
|
}
|
|
ViewEvent::ElevationDecision {
|
|
tool_id,
|
|
tool_name,
|
|
option,
|
|
} => {
|
|
use crate::tui::approval::ElevationOption;
|
|
match option {
|
|
ElevationOption::Abort => {
|
|
let _ = engine_handle.deny_tool_call(tool_id).await;
|
|
app.add_message(HistoryCell::System {
|
|
content: format!("Sandbox elevation aborted for {tool_name}"),
|
|
});
|
|
}
|
|
ElevationOption::WithNetwork => {
|
|
app.add_message(HistoryCell::System {
|
|
content: format!("Retrying {tool_name} with network access enabled"),
|
|
});
|
|
let policy = option.to_policy(&app.workspace);
|
|
let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
|
|
}
|
|
ElevationOption::WithWriteAccess(_) => {
|
|
app.add_message(HistoryCell::System {
|
|
content: format!("Retrying {tool_name} with write access enabled"),
|
|
});
|
|
let policy = option.to_policy(&app.workspace);
|
|
let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
|
|
}
|
|
ElevationOption::FullAccess => {
|
|
app.add_message(HistoryCell::System {
|
|
content: format!("Retrying {tool_name} with full access (no sandbox)"),
|
|
});
|
|
let policy = option.to_policy(&app.workspace);
|
|
let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
|
|
}
|
|
}
|
|
}
|
|
ViewEvent::UserInputSubmitted { tool_id, response } => {
|
|
let _ = engine_handle.submit_user_input(tool_id, response).await;
|
|
}
|
|
ViewEvent::UserInputCancelled { tool_id } => {
|
|
let _ = engine_handle.cancel_user_input(tool_id).await;
|
|
app.add_message(HistoryCell::System {
|
|
content: "User input cancelled".to_string(),
|
|
});
|
|
}
|
|
ViewEvent::SessionSelected { session_id } => {
|
|
let manager = match SessionManager::default_location() {
|
|
Ok(manager) => manager,
|
|
Err(err) => {
|
|
app.status_message =
|
|
Some(format!("Failed to open sessions directory: {err}"));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
match manager.load_session(&session_id) {
|
|
Ok(session) => {
|
|
apply_loaded_session(app, &session);
|
|
let _ = engine_handle
|
|
.send(Op::SyncSession {
|
|
messages: app.api_messages.clone(),
|
|
system_prompt: app.system_prompt.clone(),
|
|
model: app.model.clone(),
|
|
workspace: app.workspace.clone(),
|
|
})
|
|
.await;
|
|
app.status_message =
|
|
Some(format!("Session loaded (ID: {})", &session_id[..8]));
|
|
}
|
|
Err(err) => {
|
|
app.status_message =
|
|
Some(format!("Failed to load session {session_id}: {err}"));
|
|
}
|
|
}
|
|
}
|
|
ViewEvent::SessionDeleted { session_id, title } => {
|
|
app.status_message =
|
|
Some(format!("Deleted session {} ({})", &session_id[..8], title));
|
|
}
|
|
ViewEvent::SubAgentsRefresh => {
|
|
app.status_message = Some("Refreshing sub-agents...".to_string());
|
|
let _ = engine_handle.send(Op::ListSubAgents).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_loaded_session(app: &mut App, session: &SavedSession) {
|
|
app.api_messages.clone_from(&session.messages);
|
|
app.history.clear();
|
|
|
|
for msg in &app.api_messages {
|
|
app.history.extend(history_cells_from_message(msg));
|
|
}
|
|
app.mark_history_updated();
|
|
app.transcript_selection.clear();
|
|
app.model.clone_from(&session.metadata.model);
|
|
app.workspace.clone_from(&session.metadata.workspace);
|
|
app.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
|
|
app.total_conversation_tokens = app.total_tokens;
|
|
app.current_session_id = Some(session.metadata.id.clone());
|
|
if let Some(sp) = session.system_prompt.as_ref() {
|
|
app.system_prompt = Some(SystemPrompt::Text(sp.clone()));
|
|
} else {
|
|
app.system_prompt = None;
|
|
}
|
|
app.scroll_to_bottom();
|
|
}
|
|
|
|
fn pause_terminal(
|
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
|
use_alt_screen: bool,
|
|
) -> Result<()> {
|
|
disable_raw_mode()?;
|
|
if use_alt_screen {
|
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
}
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
DisableMouseCapture,
|
|
DisableBracketedPaste
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn resume_terminal(
|
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
|
use_alt_screen: bool,
|
|
) -> Result<()> {
|
|
enable_raw_mode()?;
|
|
if use_alt_screen {
|
|
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
|
|
}
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
EnableMouseCapture,
|
|
EnableBracketedPaste
|
|
)?;
|
|
terminal.clear()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn render_status_indicator(f: &mut Frame, area: Rect, app: &App, queued: &[String]) {
|
|
let mut lines = Vec::new();
|
|
|
|
if app.is_loading {
|
|
let header = if app.show_thinking {
|
|
app.reasoning_header.clone()
|
|
} else {
|
|
None
|
|
};
|
|
let elapsed = app.turn_started_at.map(format_elapsed);
|
|
// Use typing indicator when streaming content, otherwise use whale spinner
|
|
let has_streaming_content = app.streaming_message_index.is_some();
|
|
let spinner = if has_streaming_content {
|
|
typing_indicator(app.turn_started_at)
|
|
} else {
|
|
deepseek_squiggle(app.turn_started_at)
|
|
};
|
|
let label = if app.show_thinking {
|
|
deepseek_thinking_label(app.turn_started_at)
|
|
} else {
|
|
"Working"
|
|
};
|
|
let mut spans = vec![
|
|
Span::styled(spinner, Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
|
Span::raw(" "),
|
|
Span::styled(label, Style::default().fg(palette::STATUS_WARNING).bold()),
|
|
];
|
|
if let Some(header) = header {
|
|
spans.push(Span::raw(": "));
|
|
spans.push(Span::styled(
|
|
header,
|
|
Style::default().fg(palette::STATUS_WARNING),
|
|
));
|
|
}
|
|
|
|
if let Some(elapsed) = elapsed {
|
|
spans.push(Span::raw(" | "));
|
|
spans.push(Span::styled(
|
|
elapsed,
|
|
Style::default().fg(palette::TEXT_MUTED),
|
|
));
|
|
}
|
|
|
|
spans.push(Span::raw(" | "));
|
|
spans.push(Span::styled(
|
|
"Esc/Ctrl+C to interrupt",
|
|
Style::default().fg(palette::TEXT_MUTED),
|
|
));
|
|
|
|
lines.push(Line::from(spans));
|
|
}
|
|
|
|
if let Some(draft) = app.queued_draft.as_ref() {
|
|
let available = area.width as usize;
|
|
let prefix = "Editing queued:";
|
|
let prefix_width = prefix.width() + 1;
|
|
let max_len = available.saturating_sub(prefix_width).max(1);
|
|
let preview = truncate_line_to_width(&draft.display, max_len);
|
|
lines.push(Line::from(vec![
|
|
Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)),
|
|
Span::raw(" "),
|
|
Span::styled(preview, Style::default().fg(palette::DEEPSEEK_SKY)),
|
|
]));
|
|
}
|
|
|
|
if !queued.is_empty() {
|
|
let available = area.width as usize;
|
|
let queued_count = app.queued_message_count();
|
|
let header = format!("Queued ({queued_count}) - /queue edit <n>");
|
|
let header = truncate_line_to_width(&header, available.max(1));
|
|
lines.push(Line::from(vec![Span::styled(
|
|
header,
|
|
Style::default().fg(palette::TEXT_MUTED),
|
|
)]));
|
|
|
|
for (idx, message) in queued.iter().enumerate() {
|
|
let label = if message.starts_with('+') {
|
|
message.to_string()
|
|
} else {
|
|
format!("{}. {message}", idx + 1)
|
|
};
|
|
let preview = truncate_line_to_width(&label, available.max(1));
|
|
lines.push(Line::from(vec![Span::styled(
|
|
preview,
|
|
Style::default().fg(palette::TEXT_DIM),
|
|
)]));
|
|
}
|
|
}
|
|
|
|
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
|
f.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
|
let width = area.width;
|
|
let available_width = width as usize;
|
|
|
|
// 1. Context Progress Bar (Right)
|
|
let percent = get_context_percent_decimal(app);
|
|
let bar_width = 10; // Width of the progress bar
|
|
let filled = ((percent / 100.0) * bar_width as f32).round() as usize;
|
|
let filled = filled.min(bar_width);
|
|
let empty = bar_width - filled;
|
|
|
|
let bar_color = if percent > 90.0 {
|
|
palette::STATUS_ERROR
|
|
} else if percent > 75.0 {
|
|
palette::STATUS_WARNING
|
|
} else {
|
|
palette::DEEPSEEK_SKY
|
|
};
|
|
|
|
let bar_filled = "█".repeat(filled);
|
|
let bar_empty = "░".repeat(empty);
|
|
let context_text = format!("[{}{}] {:.0}%", bar_filled, bar_empty, percent);
|
|
let context_span = Span::styled(context_text, Style::default().fg(bar_color));
|
|
|
|
// 2. Right side extras (Scroll, Selection) - Minimalist
|
|
let mut right_extras = Vec::new();
|
|
|
|
// Scroll %
|
|
let can_scroll = app.last_transcript_total > app.last_transcript_visible;
|
|
if can_scroll && !matches!(app.transcript_scroll, TranscriptScroll::ToBottom) {
|
|
right_extras.push(Span::styled(
|
|
format!(" {}% ", app.last_transcript_top + 1),
|
|
Style::default().fg(palette::TEXT_DIM),
|
|
));
|
|
}
|
|
|
|
// Selection
|
|
if app.transcript_selection.is_active() {
|
|
right_extras.push(Span::styled(
|
|
" [SEL] ",
|
|
Style::default().fg(palette::TEXT_DIM),
|
|
));
|
|
}
|
|
|
|
// Assemble Right Side
|
|
// context_span is always last
|
|
let mut right_spans = right_extras;
|
|
right_spans.push(Span::raw(" ")); // Space before context
|
|
right_spans.push(context_span);
|
|
|
|
let right_width: usize = right_spans.iter().map(|s| s.content.width()).sum();
|
|
|
|
// 3. Left side content (Status toast or standard footer)
|
|
let left_spans = if let Some(msg) = app.status_message.as_ref() {
|
|
let max_left = available_width
|
|
.saturating_sub(right_width)
|
|
.saturating_sub(1)
|
|
.max(1);
|
|
let truncated = truncate_line_to_width(msg, max_left);
|
|
vec![Span::styled(
|
|
truncated,
|
|
Style::default().fg(palette::DEEPSEEK_SKY),
|
|
)]
|
|
} else {
|
|
// Time (Left)
|
|
let time_str = Local::now().format("%H:%M").to_string();
|
|
let time_span = Span::styled(
|
|
format!("{} ", time_str),
|
|
Style::default().fg(palette::TEXT_DIM),
|
|
);
|
|
|
|
// Mode (Left) - Lowercase, colored
|
|
let mode_str = app.mode.label().to_lowercase();
|
|
let mode_style = mode_badge_style(app.mode);
|
|
let mode_span = Span::styled(format!("{} ", mode_str), mode_style);
|
|
|
|
// Agent Info (Left)
|
|
let model = &app.model;
|
|
let status_suffix = if app.is_loading { ", thinking" } else { "" };
|
|
let agent_text = format!("agent ({}{})", model, status_suffix);
|
|
let agent_span = Span::styled(agent_text, Style::default().fg(palette::TEXT_DIM));
|
|
|
|
vec![time_span, mode_span, agent_span]
|
|
};
|
|
|
|
// Calculate Widths
|
|
let left_width: usize = left_spans.iter().map(|s| s.content.width()).sum();
|
|
|
|
// Spacer
|
|
let spacer_width = available_width.saturating_sub(left_width + right_width);
|
|
|
|
let mut all_spans = left_spans;
|
|
if spacer_width > 0 {
|
|
all_spans.push(Span::raw(" ".repeat(spacer_width)));
|
|
all_spans.extend(right_spans);
|
|
} else {
|
|
// Fallback for narrow screens: Drop agent info
|
|
let simple_left = if let Some(msg) = app.status_message.as_ref() {
|
|
let max_left = available_width.saturating_sub(10).saturating_sub(1).max(1);
|
|
let truncated = truncate_line_to_width(msg, max_left);
|
|
vec![Span::styled(
|
|
truncated,
|
|
Style::default().fg(palette::DEEPSEEK_SKY),
|
|
)]
|
|
} else {
|
|
let time_str = Local::now().format("%H:%M").to_string();
|
|
let mode_str = app.mode.label().to_lowercase();
|
|
let mode_style = mode_badge_style(app.mode);
|
|
vec![
|
|
Span::styled(
|
|
format!("{} ", time_str),
|
|
Style::default().fg(palette::TEXT_DIM),
|
|
),
|
|
Span::styled(format!("{} ", mode_str), mode_style),
|
|
]
|
|
};
|
|
let bar_filled_narrow = "█".repeat(filled.min(5));
|
|
let bar_empty_narrow = "░".repeat(5 - filled.min(5));
|
|
let simple_right = vec![Span::styled(
|
|
format!(
|
|
"[{}{}] {:.0}%",
|
|
bar_filled_narrow, bar_empty_narrow, percent
|
|
),
|
|
Style::default().fg(bar_color),
|
|
)];
|
|
|
|
let sl_width: usize = simple_left.iter().map(|s| s.content.width()).sum();
|
|
let sr_width: usize = simple_right.iter().map(|s| s.content.width()).sum();
|
|
let sp_width = available_width.saturating_sub(sl_width + sr_width);
|
|
|
|
all_spans = simple_left;
|
|
all_spans.push(Span::raw(" ".repeat(sp_width)));
|
|
all_spans.extend(simple_right);
|
|
}
|
|
|
|
let footer = Paragraph::new(Line::from(all_spans));
|
|
f.render_widget(footer, area);
|
|
}
|
|
|
|
fn get_context_percent_decimal(app: &App) -> f32 {
|
|
let used = if app.total_conversation_tokens > 0 {
|
|
Some(i64::from(app.total_conversation_tokens))
|
|
} else {
|
|
estimated_context_tokens(app)
|
|
};
|
|
|
|
if let Some(max) = context_window_for_model(&app.model) {
|
|
if let Some(used) = used {
|
|
let max_f64 = max as f64;
|
|
let used_f64 = used as f64;
|
|
let percent = (used_f64 / max_f64) * 100.0;
|
|
percent.clamp(0.0, 100.0) as f32
|
|
} else {
|
|
0.0
|
|
}
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
|
|
fn mode_color(mode: AppMode) -> Color {
|
|
match mode {
|
|
AppMode::Normal => palette::MODE_NORMAL,
|
|
AppMode::Agent => palette::MODE_AGENT,
|
|
AppMode::Yolo => palette::MODE_YOLO,
|
|
AppMode::Plan => palette::MODE_PLAN,
|
|
}
|
|
}
|
|
|
|
fn mode_badge_style(mode: AppMode) -> Style {
|
|
Style::default()
|
|
.fg(palette::TEXT_PRIMARY)
|
|
.bg(mode_color(mode))
|
|
.add_modifier(Modifier::BOLD)
|
|
}
|
|
|
|
fn prompt_for_mode(mode: AppMode) -> &'static str {
|
|
match mode {
|
|
AppMode::Normal => "> ",
|
|
AppMode::Agent => "agent> ",
|
|
AppMode::Yolo => "yolo> ",
|
|
AppMode::Plan => "plan> ",
|
|
}
|
|
}
|
|
|
|
fn estimated_context_tokens(app: &App) -> Option<i64> {
|
|
let mut total_chars = estimate_message_chars(&app.api_messages);
|
|
|
|
match &app.system_prompt {
|
|
Some(SystemPrompt::Text(text)) => total_chars = total_chars.saturating_add(text.len()),
|
|
Some(SystemPrompt::Blocks(blocks)) => {
|
|
for block in blocks {
|
|
total_chars = total_chars.saturating_add(block.text.len());
|
|
}
|
|
}
|
|
None => {}
|
|
}
|
|
|
|
let estimated_tokens = total_chars / 4;
|
|
i64::try_from(estimated_tokens).ok()
|
|
}
|
|
|
|
fn format_elapsed(start: Instant) -> String {
|
|
let elapsed = start.elapsed().as_secs();
|
|
if elapsed >= 60 {
|
|
format!("{}m{:02}s", elapsed / 60, elapsed % 60)
|
|
} else {
|
|
format!("{elapsed}s")
|
|
}
|
|
}
|
|
|
|
fn deepseek_squiggle(start: Option<Instant>) -> &'static str {
|
|
const FRAMES: [&str; 12] = [
|
|
"🐳", "🐳.", "🐳..", "🐳...", "🐳..", "🐳.", "🐋", "🐋.", "🐋..", "🐋...", "🐋..", "🐋.",
|
|
];
|
|
let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis());
|
|
let idx = ((elapsed_ms / 180) as usize) % FRAMES.len();
|
|
FRAMES[idx]
|
|
}
|
|
|
|
/// Braille pattern frames for typing/thinking indicator animation.
|
|
const TYPING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
|
|
/// Returns the typing indicator frame based on elapsed time.
|
|
fn typing_indicator(start: Option<Instant>) -> &'static str {
|
|
let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis());
|
|
let idx = ((elapsed_ms / 80) as usize) % TYPING_FRAMES.len();
|
|
TYPING_FRAMES[idx]
|
|
}
|
|
|
|
fn deepseek_thinking_label(start: Option<Instant>) -> &'static str {
|
|
const TAGLINES: [&str; 5] = [
|
|
"Thinking",
|
|
"Plotting",
|
|
"Drafting",
|
|
"You're absolutely right! ... maybe.",
|
|
"Working",
|
|
];
|
|
const INITIAL_MS: u128 = 2400;
|
|
let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis());
|
|
if elapsed_ms < INITIAL_MS {
|
|
return "Working";
|
|
}
|
|
let idx = (((elapsed_ms - INITIAL_MS) / 2400) as usize) % TAGLINES.len();
|
|
TAGLINES[idx]
|
|
}
|
|
|
|
fn truncate_line_to_width(text: &str, max_width: usize) -> String {
|
|
if max_width == 0 {
|
|
return String::new();
|
|
}
|
|
if UnicodeWidthStr::width(text) <= max_width {
|
|
return text.to_string();
|
|
}
|
|
if max_width <= 3 {
|
|
return text.chars().take(max_width).collect();
|
|
}
|
|
|
|
let mut out = String::new();
|
|
let mut width = 0usize;
|
|
let limit = max_width.saturating_sub(3);
|
|
for ch in text.chars() {
|
|
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
|
if width + ch_width > limit {
|
|
break;
|
|
}
|
|
out.push(ch);
|
|
width += ch_width;
|
|
}
|
|
out.push_str("...");
|
|
out
|
|
}
|
|
|
|
fn handle_mouse_event(app: &mut App, mouse: MouseEvent) {
|
|
match mouse.kind {
|
|
MouseEventKind::ScrollUp => {
|
|
let update = app.mouse_scroll.on_scroll(ScrollDirection::Up);
|
|
app.pending_scroll_delta += update.delta_lines;
|
|
}
|
|
MouseEventKind::ScrollDown => {
|
|
let update = app.mouse_scroll.on_scroll(ScrollDirection::Down);
|
|
app.pending_scroll_delta += update.delta_lines;
|
|
}
|
|
MouseEventKind::Down(MouseButton::Left) => {
|
|
if let Some(point) = selection_point_from_mouse(app, mouse) {
|
|
app.transcript_selection.anchor = Some(point);
|
|
app.transcript_selection.head = Some(point);
|
|
app.transcript_selection.dragging = true;
|
|
|
|
if app.is_loading
|
|
&& matches!(app.transcript_scroll, TranscriptScroll::ToBottom)
|
|
&& let Some(anchor) = TranscriptScroll::anchor_for(
|
|
app.transcript_cache.line_meta(),
|
|
app.last_transcript_top,
|
|
)
|
|
{
|
|
app.transcript_scroll = anchor;
|
|
}
|
|
} else if app.transcript_selection.is_active() {
|
|
app.transcript_selection.clear();
|
|
}
|
|
}
|
|
MouseEventKind::Drag(MouseButton::Left) => {
|
|
if app.transcript_selection.dragging
|
|
&& let Some(point) = selection_point_from_mouse(app, mouse)
|
|
{
|
|
app.transcript_selection.head = Some(point);
|
|
}
|
|
}
|
|
MouseEventKind::Up(MouseButton::Left) => {
|
|
if app.transcript_selection.dragging {
|
|
app.transcript_selection.dragging = false;
|
|
if selection_has_content(app) {
|
|
copy_active_selection(app);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn selection_point_from_mouse(app: &App, mouse: MouseEvent) -> Option<TranscriptSelectionPoint> {
|
|
selection_point_from_position(
|
|
app.last_transcript_area?,
|
|
mouse.column,
|
|
mouse.row,
|
|
app.last_transcript_top,
|
|
app.last_transcript_total,
|
|
app.last_transcript_padding_top,
|
|
)
|
|
}
|
|
|
|
fn selection_point_from_position(
|
|
area: Rect,
|
|
column: u16,
|
|
row: u16,
|
|
transcript_top: usize,
|
|
transcript_total: usize,
|
|
padding_top: usize,
|
|
) -> Option<TranscriptSelectionPoint> {
|
|
if column < area.x
|
|
|| column >= area.x + area.width
|
|
|| row < area.y
|
|
|| row >= area.y + area.height
|
|
{
|
|
return None;
|
|
}
|
|
|
|
if transcript_total == 0 {
|
|
return None;
|
|
}
|
|
|
|
let row = row.saturating_sub(area.y) as usize;
|
|
if row < padding_top {
|
|
return None;
|
|
}
|
|
let row = row.saturating_sub(padding_top);
|
|
|
|
let col = column.saturating_sub(area.x) as usize;
|
|
let line_index = transcript_top
|
|
.saturating_add(row)
|
|
.min(transcript_total.saturating_sub(1));
|
|
|
|
Some(TranscriptSelectionPoint {
|
|
line_index,
|
|
column: col,
|
|
})
|
|
}
|
|
|
|
fn selection_has_content(app: &App) -> bool {
|
|
match app.transcript_selection.ordered_endpoints() {
|
|
Some((start, end)) => start != end,
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
fn copy_active_selection(app: &mut App) {
|
|
if !app.transcript_selection.is_active() {
|
|
return;
|
|
}
|
|
if let Some(text) = selection_to_text(app) {
|
|
if app.clipboard.write_text(&text).is_ok() {
|
|
app.status_message = Some("Selection copied".to_string());
|
|
} else {
|
|
app.status_message = Some("Copy failed".to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn selection_to_text(app: &App) -> Option<String> {
|
|
let (start, end) = app.transcript_selection.ordered_endpoints()?;
|
|
let lines = app.transcript_cache.lines();
|
|
if lines.is_empty() {
|
|
return None;
|
|
}
|
|
let end_index = end.line_index.min(lines.len().saturating_sub(1));
|
|
let start_index = start.line_index.min(end_index);
|
|
|
|
let mut out = String::new();
|
|
#[allow(clippy::needless_range_loop)]
|
|
for line_index in start_index..=end_index {
|
|
let line_text = line_to_plain(&lines[line_index]);
|
|
let slice = if start_index == end_index {
|
|
slice_text(&line_text, start.column, end.column)
|
|
} else if line_index == start_index {
|
|
slice_text(&line_text, start.column, line_text.chars().count())
|
|
} else if line_index == end_index {
|
|
slice_text(&line_text, 0, end.column)
|
|
} else {
|
|
line_text
|
|
};
|
|
out.push_str(&slice);
|
|
if line_index != end_index {
|
|
out.push('\n');
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
fn open_pager_for_selection(app: &mut App) -> bool {
|
|
let Some(text) = selection_to_text(app) else {
|
|
return false;
|
|
};
|
|
let width = app
|
|
.last_transcript_area
|
|
.map(|area| area.width)
|
|
.unwrap_or(80);
|
|
let pager = PagerView::from_text("Selection", &text, width.saturating_sub(2));
|
|
app.view_stack.push(pager);
|
|
true
|
|
}
|
|
|
|
fn open_pager_for_last_message(app: &mut App) -> bool {
|
|
let Some(cell) = app.history.last() else {
|
|
return false;
|
|
};
|
|
let width = app
|
|
.last_transcript_area
|
|
.map(|area| area.width)
|
|
.unwrap_or(80);
|
|
let text = history_cell_to_text(cell, width);
|
|
let pager = PagerView::from_text("Message", &text, width.saturating_sub(2));
|
|
app.view_stack.push(pager);
|
|
true
|
|
}
|
|
|
|
fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
|
|
cell.lines(width)
|
|
.into_iter()
|
|
.map(line_to_string)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn line_to_string(line: Line<'static>) -> String {
|
|
line.spans
|
|
.into_iter()
|
|
.map(|span| span.content.to_string())
|
|
.collect::<String>()
|
|
}
|
|
|
|
fn is_copy_shortcut(key: &KeyEvent) -> bool {
|
|
let is_c = matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'));
|
|
if !is_c {
|
|
return false;
|
|
}
|
|
|
|
if key.modifiers.contains(KeyModifiers::SUPER) {
|
|
return true;
|
|
}
|
|
|
|
key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT)
|
|
}
|
|
|
|
fn is_paste_shortcut(key: &KeyEvent) -> bool {
|
|
let is_v = matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V'));
|
|
if !is_v {
|
|
return false;
|
|
}
|
|
|
|
// Cmd+V on macOS
|
|
if key.modifiers.contains(KeyModifiers::SUPER) {
|
|
return true;
|
|
}
|
|
|
|
// Ctrl+V on Linux/Windows
|
|
key.modifiers.contains(KeyModifiers::CONTROL)
|
|
}
|
|
|
|
fn should_scroll_with_arrows(_app: &App) -> bool {
|
|
false
|
|
}
|
|
|
|
fn line_to_plain(line: &Line<'static>) -> String {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
}
|
|
|
|
fn slice_text(text: &str, start: usize, end: usize) -> String {
|
|
let mut out = String::new();
|
|
let mut idx = 0usize;
|
|
for ch in text.chars() {
|
|
if idx >= start && idx < end {
|
|
out.push(ch);
|
|
}
|
|
idx += 1;
|
|
if idx >= end {
|
|
break;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn extract_reasoning_header(text: &str) -> Option<String> {
|
|
let start = text.find("**")?;
|
|
let rest = &text[start + 2..];
|
|
let end = rest.find("**")?;
|
|
let header = rest[..end].trim().trim_end_matches(':');
|
|
if header.is_empty() {
|
|
None
|
|
} else {
|
|
Some(header.to_string())
|
|
}
|
|
}
|
|
|
|
fn format_subagent_list(agents: &[SubAgentResult]) -> String {
|
|
if agents.is_empty() {
|
|
return "No sub-agents running.".to_string();
|
|
}
|
|
|
|
let mut lines = Vec::new();
|
|
lines.push("Sub-agents:".to_string());
|
|
lines.push("----------------------------------------".to_string());
|
|
|
|
for agent in agents {
|
|
let status = format_subagent_status(&agent.status);
|
|
let mut line = format!(
|
|
" {} ({:?}) - {} | steps: {} | {}ms",
|
|
agent.agent_id, agent.agent_type, status, agent.steps_taken, agent.duration_ms
|
|
);
|
|
if matches!(agent.status, SubAgentStatus::Completed)
|
|
&& let Some(result) = agent.result.as_ref()
|
|
{
|
|
let _ = write!(line, "\n Result: {}", summarize_tool_output(result));
|
|
}
|
|
lines.push(line);
|
|
}
|
|
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn format_subagent_status(status: &SubAgentStatus) -> String {
|
|
match status {
|
|
SubAgentStatus::Running => "running".to_string(),
|
|
SubAgentStatus::Completed => "completed".to_string(),
|
|
SubAgentStatus::Cancelled => "cancelled".to_string(),
|
|
SubAgentStatus::Failed(err) => format!("failed: {}", summarize_tool_output(err)),
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
fn handle_tool_call_started(app: &mut App, id: &str, name: &str, input: &serde_json::Value) {
|
|
let id = id.to_string();
|
|
if is_exploring_tool(name) {
|
|
let label = exploring_label(name, input);
|
|
let cell_index = if let Some(idx) = app.exploring_cell {
|
|
idx
|
|
} else {
|
|
app.add_message(HistoryCell::Tool(ToolCell::Exploring(ExploringCell {
|
|
entries: Vec::new(),
|
|
})));
|
|
let idx = app.history.len().saturating_sub(1);
|
|
app.exploring_cell = Some(idx);
|
|
idx
|
|
};
|
|
|
|
if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) = app.history.get_mut(cell_index)
|
|
{
|
|
let entry_index = cell.insert_entry(ExploringEntry {
|
|
label,
|
|
status: ToolStatus::Running,
|
|
});
|
|
app.mark_history_updated();
|
|
app.exploring_entries
|
|
.insert(id.clone(), (cell_index, entry_index));
|
|
}
|
|
app.tool_cells.insert(id, cell_index);
|
|
return;
|
|
}
|
|
|
|
app.exploring_cell = None;
|
|
|
|
if is_exec_tool(name) {
|
|
let command = exec_command_from_input(input).unwrap_or_else(|| "<command>".to_string());
|
|
let source = exec_source_from_input(input);
|
|
let interaction = exec_interaction_summary(name, input);
|
|
let mut is_wait = false;
|
|
|
|
if let Some((summary, wait)) = interaction.as_ref() {
|
|
is_wait = *wait;
|
|
if is_wait
|
|
&& app
|
|
.last_exec_wait_command
|
|
.as_ref()
|
|
.is_some_and(|last| last == &command)
|
|
{
|
|
app.ignored_tool_calls.insert(id);
|
|
return;
|
|
}
|
|
if is_wait {
|
|
app.last_exec_wait_command = Some(command.clone());
|
|
}
|
|
|
|
app.add_message(HistoryCell::Tool(ToolCell::Exec(ExecCell {
|
|
command,
|
|
status: ToolStatus::Running,
|
|
output: None,
|
|
started_at: Some(Instant::now()),
|
|
duration_ms: None,
|
|
source,
|
|
interaction: Some(summary.clone()),
|
|
})));
|
|
app.tool_cells
|
|
.insert(id, app.history.len().saturating_sub(1));
|
|
return;
|
|
}
|
|
|
|
if exec_is_background(input)
|
|
&& app
|
|
.last_exec_wait_command
|
|
.as_ref()
|
|
.is_some_and(|last| last == &command)
|
|
{
|
|
app.ignored_tool_calls.insert(id);
|
|
return;
|
|
}
|
|
if exec_is_background(input) && !is_wait {
|
|
app.last_exec_wait_command = Some(command.clone());
|
|
}
|
|
|
|
app.add_message(HistoryCell::Tool(ToolCell::Exec(ExecCell {
|
|
command,
|
|
status: ToolStatus::Running,
|
|
output: None,
|
|
started_at: Some(Instant::now()),
|
|
duration_ms: None,
|
|
source,
|
|
interaction: None,
|
|
})));
|
|
app.tool_cells
|
|
.insert(id, app.history.len().saturating_sub(1));
|
|
return;
|
|
}
|
|
|
|
if name == "update_plan" {
|
|
let (explanation, steps) = parse_plan_input(input);
|
|
app.add_message(HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
|
|
explanation,
|
|
steps,
|
|
status: ToolStatus::Running,
|
|
})));
|
|
app.tool_cells
|
|
.insert(id, app.history.len().saturating_sub(1));
|
|
return;
|
|
}
|
|
|
|
if name == "apply_patch" {
|
|
let (path, summary) = parse_patch_summary(input);
|
|
app.add_message(HistoryCell::Tool(ToolCell::PatchSummary(
|
|
PatchSummaryCell {
|
|
path,
|
|
summary,
|
|
status: ToolStatus::Running,
|
|
error: None,
|
|
},
|
|
)));
|
|
app.tool_cells
|
|
.insert(id, app.history.len().saturating_sub(1));
|
|
return;
|
|
}
|
|
|
|
if name == "review" {
|
|
let target = review_target_label(input);
|
|
app.add_message(HistoryCell::Tool(ToolCell::Review(ReviewCell {
|
|
target,
|
|
status: ToolStatus::Running,
|
|
output: None,
|
|
error: None,
|
|
})));
|
|
app.tool_cells
|
|
.insert(id, app.history.len().saturating_sub(1));
|
|
return;
|
|
}
|
|
|
|
if is_mcp_tool(name) {
|
|
app.add_message(HistoryCell::Tool(ToolCell::Mcp(McpToolCell {
|
|
tool: name.to_string(),
|
|
status: ToolStatus::Running,
|
|
content: None,
|
|
is_image: false,
|
|
})));
|
|
app.tool_cells
|
|
.insert(id, app.history.len().saturating_sub(1));
|
|
return;
|
|
}
|
|
|
|
if is_view_image_tool(name) {
|
|
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
|
|
let raw_path = PathBuf::from(path);
|
|
let display_path = raw_path
|
|
.strip_prefix(&app.workspace)
|
|
.unwrap_or(&raw_path)
|
|
.to_path_buf();
|
|
app.add_message(HistoryCell::Tool(ToolCell::ViewImage(ViewImageCell {
|
|
path: display_path,
|
|
})));
|
|
app.tool_cells
|
|
.insert(id, app.history.len().saturating_sub(1));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if is_web_search_tool(name) {
|
|
let query = web_search_query(input);
|
|
app.add_message(HistoryCell::Tool(ToolCell::WebSearch(WebSearchCell {
|
|
query,
|
|
status: ToolStatus::Running,
|
|
summary: None,
|
|
})));
|
|
app.tool_cells
|
|
.insert(id, app.history.len().saturating_sub(1));
|
|
return;
|
|
}
|
|
|
|
let input_summary = summarize_tool_args(input);
|
|
app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
|
name: name.to_string(),
|
|
status: ToolStatus::Running,
|
|
input_summary,
|
|
output: None,
|
|
})));
|
|
app.tool_cells
|
|
.insert(id, app.history.len().saturating_sub(1));
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
fn handle_tool_call_complete(
|
|
app: &mut App,
|
|
id: &str,
|
|
_name: &str,
|
|
result: &Result<ToolResult, ToolError>,
|
|
) {
|
|
if app.ignored_tool_calls.remove(id) {
|
|
return;
|
|
}
|
|
|
|
if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) {
|
|
if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) = app.history.get_mut(cell_index)
|
|
&& let Some(entry) = cell.entries.get_mut(entry_index)
|
|
{
|
|
entry.status = match result.as_ref() {
|
|
Ok(tool_result) if tool_result.success => ToolStatus::Success,
|
|
Ok(_) | Err(_) => ToolStatus::Failed,
|
|
};
|
|
app.mark_history_updated();
|
|
}
|
|
return;
|
|
}
|
|
|
|
let Some(cell_index) = app.tool_cells.remove(id) else {
|
|
return;
|
|
};
|
|
|
|
let status = match result.as_ref() {
|
|
Ok(tool_result) => match tool_result.metadata.as_ref() {
|
|
Some(meta)
|
|
if meta
|
|
.get("status")
|
|
.and_then(|v| v.as_str())
|
|
.is_some_and(|s| s == "Running") =>
|
|
{
|
|
ToolStatus::Running
|
|
}
|
|
_ => {
|
|
if tool_result.success {
|
|
ToolStatus::Success
|
|
} else {
|
|
ToolStatus::Failed
|
|
}
|
|
}
|
|
},
|
|
Err(_) => ToolStatus::Failed,
|
|
};
|
|
|
|
if let Some(cell) = app.history.get_mut(cell_index) {
|
|
match cell {
|
|
HistoryCell::Tool(ToolCell::Exec(exec)) => {
|
|
exec.status = status;
|
|
if let Ok(tool_result) = result.as_ref() {
|
|
exec.duration_ms = tool_result
|
|
.metadata
|
|
.as_ref()
|
|
.and_then(|m| m.get("duration_ms"))
|
|
.and_then(serde_json::Value::as_u64);
|
|
if status != ToolStatus::Running && exec.interaction.is_none() {
|
|
exec.output = Some(tool_result.content.clone());
|
|
}
|
|
} else if let Err(err) = result.as_ref()
|
|
&& exec.interaction.is_none()
|
|
{
|
|
exec.output = Some(err.to_string());
|
|
}
|
|
app.mark_history_updated();
|
|
}
|
|
HistoryCell::Tool(ToolCell::PlanUpdate(plan)) => {
|
|
plan.status = status;
|
|
app.mark_history_updated();
|
|
}
|
|
HistoryCell::Tool(ToolCell::PatchSummary(patch)) => {
|
|
patch.status = status;
|
|
match result.as_ref() {
|
|
Ok(tool_result) => {
|
|
if let Ok(json) =
|
|
serde_json::from_str::<serde_json::Value>(&tool_result.content)
|
|
&& let Some(message) = json.get("message").and_then(|v| v.as_str())
|
|
{
|
|
patch.summary = message.to_string();
|
|
}
|
|
}
|
|
Err(err) => {
|
|
patch.error = Some(err.to_string());
|
|
}
|
|
}
|
|
app.mark_history_updated();
|
|
}
|
|
HistoryCell::Tool(ToolCell::Review(review)) => {
|
|
review.status = status;
|
|
match result.as_ref() {
|
|
Ok(tool_result) => {
|
|
if tool_result.success {
|
|
review.output = Some(ReviewOutput::from_str(&tool_result.content));
|
|
} else {
|
|
review.error = Some(tool_result.content.clone());
|
|
}
|
|
}
|
|
Err(err) => {
|
|
review.error = Some(err.to_string());
|
|
}
|
|
}
|
|
app.mark_history_updated();
|
|
}
|
|
HistoryCell::Tool(ToolCell::Mcp(mcp)) => {
|
|
match result.as_ref() {
|
|
Ok(tool_result) => {
|
|
let summary = summarize_mcp_output(&tool_result.content);
|
|
if summary.is_error == Some(true) {
|
|
mcp.status = ToolStatus::Failed;
|
|
} else {
|
|
mcp.status = status;
|
|
}
|
|
mcp.is_image = summary.is_image;
|
|
mcp.content = summary.content;
|
|
}
|
|
Err(err) => {
|
|
mcp.status = status;
|
|
mcp.content = Some(err.to_string());
|
|
}
|
|
}
|
|
app.mark_history_updated();
|
|
}
|
|
HistoryCell::Tool(ToolCell::WebSearch(search)) => {
|
|
search.status = status;
|
|
match result.as_ref() {
|
|
Ok(tool_result) => {
|
|
search.summary = Some(summarize_tool_output(&tool_result.content));
|
|
}
|
|
Err(err) => {
|
|
search.summary = Some(err.to_string());
|
|
}
|
|
}
|
|
app.mark_history_updated();
|
|
}
|
|
HistoryCell::Tool(ToolCell::Generic(generic)) => {
|
|
generic.status = status;
|
|
match result.as_ref() {
|
|
Ok(tool_result) => {
|
|
generic.output = Some(summarize_tool_output(&tool_result.content));
|
|
}
|
|
Err(err) => {
|
|
generic.output = Some(err.to_string());
|
|
}
|
|
}
|
|
app.mark_history_updated();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_exploring_tool(name: &str) -> bool {
|
|
matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files")
|
|
}
|
|
|
|
fn is_exec_tool(name: &str) -> bool {
|
|
matches!(
|
|
name,
|
|
"exec_shell" | "exec_shell_wait" | "exec_shell_interact" | "exec_wait" | "exec_interact"
|
|
)
|
|
}
|
|
|
|
fn exploring_label(name: &str, input: &serde_json::Value) -> String {
|
|
let fallback = format!("{name} tool");
|
|
let obj = input.as_object();
|
|
match name {
|
|
"read_file" => obj
|
|
.and_then(|o| o.get("path"))
|
|
.and_then(|v| v.as_str())
|
|
.map_or(fallback, |path| format!("Read {path}")),
|
|
"list_dir" => obj
|
|
.and_then(|o| o.get("path"))
|
|
.and_then(|v| v.as_str())
|
|
.map_or("List directory".to_string(), |path| format!("List {path}")),
|
|
"grep_files" => {
|
|
let pattern = obj
|
|
.and_then(|o| o.get("pattern"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("pattern");
|
|
format!("Search {pattern}")
|
|
}
|
|
"list_files" => "List files".to_string(),
|
|
_ => fallback,
|
|
}
|
|
}
|
|
|
|
fn is_mcp_tool(name: &str) -> bool {
|
|
name.starts_with("mcp_")
|
|
}
|
|
|
|
fn is_view_image_tool(name: &str) -> bool {
|
|
matches!(name, "view_image" | "view_image_file" | "view_image_tool")
|
|
}
|
|
|
|
fn is_web_search_tool(name: &str) -> bool {
|
|
matches!(name, "web_search" | "search_web" | "search" | "web.run")
|
|
|| name.ends_with("_web_search")
|
|
}
|
|
|
|
fn web_search_query(input: &serde_json::Value) -> String {
|
|
if let Some(searches) = input.get("search_query").and_then(|v| v.as_array()) {
|
|
if let Some(first) = searches.first() {
|
|
if let Some(q) = first.get("q").and_then(|v| v.as_str()) {
|
|
return q.to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
input
|
|
.get("query")
|
|
.or_else(|| input.get("q"))
|
|
.or_else(|| input.get("search"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("Web search")
|
|
.to_string()
|
|
}
|
|
|
|
fn review_target_label(input: &serde_json::Value) -> String {
|
|
let target = input
|
|
.get("target")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("review")
|
|
.trim();
|
|
let kind = input
|
|
.get("kind")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.trim()
|
|
.to_ascii_lowercase();
|
|
let staged = input
|
|
.get("staged")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
let target_lower = target.to_ascii_lowercase();
|
|
|
|
if kind == "diff"
|
|
|| target_lower == "diff"
|
|
|| target_lower == "git diff"
|
|
|| target_lower == "staged"
|
|
|| target_lower == "cached"
|
|
{
|
|
if staged || target_lower == "staged" || target_lower == "cached" {
|
|
return "git diff --cached".to_string();
|
|
}
|
|
return "git diff".to_string();
|
|
}
|
|
|
|
target.to_string()
|
|
}
|
|
|
|
fn parse_plan_input(input: &serde_json::Value) -> (Option<String>, Vec<PlanStep>) {
|
|
let explanation = input
|
|
.get("explanation")
|
|
.and_then(|v| v.as_str())
|
|
.map(std::string::ToString::to_string);
|
|
let mut steps = Vec::new();
|
|
if let Some(items) = input.get("plan").and_then(|v| v.as_array()) {
|
|
for item in items {
|
|
let step = item.get("step").and_then(|v| v.as_str()).unwrap_or("");
|
|
let status = item
|
|
.get("status")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("pending");
|
|
if !step.is_empty() {
|
|
steps.push(PlanStep {
|
|
step: step.to_string(),
|
|
status: status.to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
(explanation, steps)
|
|
}
|
|
|
|
fn parse_patch_summary(input: &serde_json::Value) -> (String, String) {
|
|
if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) {
|
|
let count = changes.len();
|
|
let path = changes
|
|
.get(0)
|
|
.and_then(|c| c.get("path"))
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| "<file>".to_string());
|
|
let label = if count <= 1 {
|
|
path
|
|
} else {
|
|
format!("{count} files")
|
|
};
|
|
let summary = format!("Changes: {count} file(s)");
|
|
return (label, summary);
|
|
}
|
|
|
|
let patch_text = input.get("patch").and_then(|v| v.as_str()).unwrap_or("");
|
|
let paths = extract_patch_paths(patch_text);
|
|
let path = input
|
|
.get("path")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string)
|
|
.or_else(|| {
|
|
if paths.len() == 1 {
|
|
paths.first().cloned()
|
|
} else if paths.is_empty() {
|
|
None
|
|
} else {
|
|
Some(format!("{} files", paths.len()))
|
|
}
|
|
})
|
|
.unwrap_or_else(|| "<file>".to_string());
|
|
|
|
let (adds, removes) = count_patch_changes(patch_text);
|
|
let summary = if adds == 0 && removes == 0 {
|
|
"Patch applied".to_string()
|
|
} else {
|
|
format!("Changes: +{adds} / -{removes}")
|
|
};
|
|
(path, summary)
|
|
}
|
|
|
|
fn extract_patch_paths(patch: &str) -> Vec<String> {
|
|
let mut paths = Vec::new();
|
|
for line in patch.lines() {
|
|
if let Some(rest) = line.strip_prefix("+++ ") {
|
|
let raw = rest.trim();
|
|
if raw == "/dev/null" || raw == "dev/null" {
|
|
continue;
|
|
}
|
|
let raw = raw.strip_prefix("b/").unwrap_or(raw);
|
|
if !paths.contains(&raw.to_string()) {
|
|
paths.push(raw.to_string());
|
|
}
|
|
} else if let Some(rest) = line.strip_prefix("diff --git ") {
|
|
let parts: Vec<&str> = rest.split_whitespace().collect();
|
|
if let Some(path) = parts.get(1).or_else(|| parts.get(0)) {
|
|
let raw = path.trim();
|
|
let raw = raw
|
|
.strip_prefix("b/")
|
|
.or_else(|| raw.strip_prefix("a/"))
|
|
.unwrap_or(raw);
|
|
if !paths.contains(&raw.to_string()) {
|
|
paths.push(raw.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
paths
|
|
}
|
|
|
|
fn maybe_add_patch_preview(app: &mut App, input: &serde_json::Value) {
|
|
if let Some(patch) = input.get("patch").and_then(|v| v.as_str()) {
|
|
app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell {
|
|
title: "Patch Preview".to_string(),
|
|
diff: patch.to_string(),
|
|
})));
|
|
app.mark_history_updated();
|
|
return;
|
|
}
|
|
|
|
if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) {
|
|
let preview = format_changes_preview(changes);
|
|
if !preview.trim().is_empty() {
|
|
app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell {
|
|
title: "Changes Preview".to_string(),
|
|
diff: preview,
|
|
})));
|
|
app.mark_history_updated();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn format_changes_preview(changes: &[serde_json::Value]) -> String {
|
|
let mut out = String::new();
|
|
for change in changes {
|
|
let path = change
|
|
.get("path")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("<file>");
|
|
let content = change.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
|
|
|
out.push_str(&format!("diff --git a/{path} b/{path}\n"));
|
|
out.push_str(&format!("--- a/{path}\n+++ b/{path}\n"));
|
|
out.push_str("@@ -0,0 +1,1 @@\n");
|
|
|
|
let mut count = 0usize;
|
|
for line in content.lines() {
|
|
out.push('+');
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
count += 1;
|
|
if count >= 20 {
|
|
out.push_str("+... (truncated)\n");
|
|
break;
|
|
}
|
|
}
|
|
if content.is_empty() {
|
|
out.push_str("+\n");
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn count_patch_changes(patch: &str) -> (usize, usize) {
|
|
let mut adds = 0;
|
|
let mut removes = 0;
|
|
for line in patch.lines() {
|
|
if line.starts_with("+++") || line.starts_with("---") {
|
|
continue;
|
|
}
|
|
if line.starts_with('+') {
|
|
adds += 1;
|
|
} else if line.starts_with('-') {
|
|
removes += 1;
|
|
}
|
|
}
|
|
(adds, removes)
|
|
}
|
|
|
|
fn exec_command_from_input(input: &serde_json::Value) -> Option<String> {
|
|
input
|
|
.get("command")
|
|
.and_then(|v| v.as_str())
|
|
.map(std::string::ToString::to_string)
|
|
}
|
|
|
|
fn exec_source_from_input(input: &serde_json::Value) -> ExecSource {
|
|
match input.get("source").and_then(|v| v.as_str()) {
|
|
Some(source) if source.eq_ignore_ascii_case("user") => ExecSource::User,
|
|
_ => ExecSource::Assistant,
|
|
}
|
|
}
|
|
|
|
fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(String, bool)> {
|
|
let command = exec_command_from_input(input).unwrap_or_else(|| "<command>".to_string());
|
|
let command_display = format!("\"{command}\"");
|
|
let interaction_input = input
|
|
.get("input")
|
|
.or_else(|| input.get("stdin"))
|
|
.or_else(|| input.get("data"))
|
|
.and_then(|v| v.as_str());
|
|
|
|
let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait");
|
|
let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact");
|
|
|
|
if is_interact_tool || interaction_input.is_some() {
|
|
let preview = interaction_input.map(summarize_interaction_input);
|
|
let summary = if let Some(preview) = preview {
|
|
format!("Interacted with {command_display}, sent {preview}")
|
|
} else {
|
|
format!("Interacted with {command_display}")
|
|
};
|
|
return Some((summary, false));
|
|
}
|
|
|
|
if is_wait_tool || input.get("wait").and_then(serde_json::Value::as_bool) == Some(true) {
|
|
return Some((format!("Waited for {command_display}"), true));
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn summarize_interaction_input(input: &str) -> String {
|
|
let mut single_line = input.replace('\r', "");
|
|
single_line = single_line.replace('\n', "\\n");
|
|
single_line = single_line.replace('\"', "'");
|
|
let max_len = 80;
|
|
if single_line.chars().count() <= max_len {
|
|
return format!("\"{single_line}\"");
|
|
}
|
|
let mut out = String::new();
|
|
for ch in single_line.chars().take(max_len.saturating_sub(3)) {
|
|
out.push(ch);
|
|
}
|
|
out.push_str("...");
|
|
format!("\"{out}\"")
|
|
}
|
|
|
|
fn exec_is_background(input: &serde_json::Value) -> bool {
|
|
input
|
|
.get("background")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn selection_point_from_position_ignores_top_padding() {
|
|
let area = Rect {
|
|
x: 10,
|
|
y: 20,
|
|
width: 30,
|
|
height: 5,
|
|
};
|
|
|
|
// Content is bottom-aligned: 2 transcript lines in a 5-row viewport.
|
|
let padding_top = 3;
|
|
let transcript_top = 0;
|
|
let transcript_total = 2;
|
|
|
|
// Click in padding area -> no selection
|
|
assert!(
|
|
selection_point_from_position(
|
|
area,
|
|
area.x + 1,
|
|
area.y,
|
|
transcript_top,
|
|
transcript_total,
|
|
padding_top,
|
|
)
|
|
.is_none()
|
|
);
|
|
|
|
// First transcript line is at row `padding_top`
|
|
let p0 = selection_point_from_position(
|
|
area,
|
|
area.x + 2,
|
|
area.y + u16::try_from(padding_top).unwrap(),
|
|
transcript_top,
|
|
transcript_total,
|
|
padding_top,
|
|
)
|
|
.expect("point");
|
|
assert_eq!(p0.line_index, 0);
|
|
assert_eq!(p0.column, 2);
|
|
|
|
// Second transcript line is one row below
|
|
let p1 = selection_point_from_position(
|
|
area,
|
|
area.x,
|
|
area.y + u16::try_from(padding_top + 1).unwrap(),
|
|
transcript_top,
|
|
transcript_total,
|
|
padding_top,
|
|
)
|
|
.expect("point");
|
|
assert_eq!(p1.line_index, 1);
|
|
assert_eq!(p1.column, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_plan_choice_accepts_numbers() {
|
|
assert_eq!(parse_plan_choice("1"), Some(PlanChoice::ImplementAgent));
|
|
assert_eq!(parse_plan_choice("2"), Some(PlanChoice::ImplementYolo));
|
|
assert_eq!(parse_plan_choice("3"), Some(PlanChoice::RevisePlan));
|
|
assert_eq!(parse_plan_choice("4"), Some(PlanChoice::ExitPlan));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_plan_choice_accepts_aliases() {
|
|
assert_eq!(parse_plan_choice("agent"), Some(PlanChoice::ImplementAgent));
|
|
assert_eq!(parse_plan_choice("yolo"), Some(PlanChoice::ImplementYolo));
|
|
assert_eq!(parse_plan_choice("revise"), Some(PlanChoice::RevisePlan));
|
|
assert_eq!(parse_plan_choice("exit"), Some(PlanChoice::ExitPlan));
|
|
assert_eq!(parse_plan_choice("unknown"), None);
|
|
}
|
|
}
|