Refactor scrolling methods in ScrollState to accept an amount parameter
- Updated `scroll_up` and `scroll_down` methods to take an `amount` parameter for more flexible scrolling. - Removed the `AgentMode` enum and related methods from the types module to simplify state management. - Modified `AppStateRest` to remove the `mode` field and adjusted related logic. - Enhanced `run_subagent` to build tool definitions and handle API key resolution from configuration. - Updated command parsing to reflect changes in login handling. - Removed onboarding overlays and related logic from input handling and rendering. - Improved status bar to reflect connection status and agent readiness. - Adjusted workflow panel rendering to simplify phase status display. - Refactored edit log initialization to load from disk if available. - Updated settings structure to use a HashMap for API keys. - Enhanced error handling in LlmClient for authentication issues.
This commit is contained in:
@@ -6,7 +6,7 @@ use crate::app::mode::ModeKind;
|
||||
use crate::app::review::{should_trigger_review, trigger_review};
|
||||
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
|
||||
use crate::app::state::runtime::TurnEvent;
|
||||
use crate::app::state::types::{AgentMode, Origin, Overlay, Toast, ToastKind};
|
||||
use crate::app::state::types::{Origin, Overlay, Toast, ToastKind};
|
||||
use crate::dto::chat::message::{ChatMessage, Role};
|
||||
|
||||
const MAX_TOOL_ONLY_TURNS: usize = 6;
|
||||
@@ -60,6 +60,7 @@ pub enum Action {
|
||||
command: String,
|
||||
},
|
||||
ModelList,
|
||||
AbortTurn,
|
||||
}
|
||||
|
||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
@@ -77,8 +78,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
ModeKind::Help => Overlay::Help,
|
||||
ModeKind::Settings => Overlay::Settings,
|
||||
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
||||
ModeKind::Onboard => Overlay::Onboard,
|
||||
ModeKind::OnboardProvider => Overlay::OnboardProvider,
|
||||
|
||||
ModeKind::KeyInput => Overlay::KeyInput,
|
||||
ModeKind::Editor => Overlay::Editor,
|
||||
ModeKind::Effort => Overlay::Effort,
|
||||
@@ -132,12 +132,11 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::ScrollUp => {
|
||||
state.scroll.scroll_up();
|
||||
state.scroll.scroll_up(5);
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::ScrollDown => {
|
||||
let total = state.transcript_cache.messages.len();
|
||||
state.scroll.scroll_down(total);
|
||||
state.scroll.scroll_down(5);
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::OpenOverlay(overlay) => {
|
||||
@@ -419,6 +418,10 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
Action::AbortTurn => {
|
||||
state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string()));
|
||||
}
|
||||
Action::LessonAccept { name } => {
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
let _ = crate::app::review::resolve_pending_lesson(
|
||||
@@ -459,7 +462,7 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
if messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut api_key = state.settings.api_key.clone().unwrap_or_default();
|
||||
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
|
||||
let model = state.settings.model.clone();
|
||||
let base_url = state.app_config.providers.get(&state.settings.provider)
|
||||
.map(|p| p.api_base.clone());
|
||||
@@ -482,12 +485,14 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
tools.extend(state.mcp_manager.as_tools());
|
||||
let tool_defs = crate::tool::tool_defs(&tools);
|
||||
let ctx = state.tool_ctx();
|
||||
let mode = state.mode;
|
||||
|
||||
let edit_session_dir = state.session_dir.clone();
|
||||
let session_id = state.session_id.clone();
|
||||
let turn_events = state.turn_events.clone();
|
||||
let in_flight_flag = state.turn_in_flight.clone();
|
||||
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
|
||||
let abort_flag = state.abort_flag.clone();
|
||||
abort_flag.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
*in_flight_flag.lock().unwrap() = true;
|
||||
|
||||
@@ -502,13 +507,14 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
tdefs: tool_defs,
|
||||
tools,
|
||||
ctx,
|
||||
mode,
|
||||
|
||||
workspace_roots,
|
||||
edit_log_session_dir: edit_session_dir,
|
||||
session_id,
|
||||
db,
|
||||
temperature,
|
||||
max_tokens,
|
||||
abort_flag,
|
||||
};
|
||||
let result = run_agent_turn(tc, &messages, &events_q);
|
||||
if let Err(e) = result {
|
||||
@@ -527,13 +533,14 @@ struct TurnCtx {
|
||||
tdefs: Vec<crate::dto::provider::request::ToolDef>,
|
||||
tools: Vec<Box<dyn crate::tool::Tool>>,
|
||||
ctx: crate::tool::ToolCtx,
|
||||
mode: AgentMode,
|
||||
|
||||
workspace_roots: Vec<std::path::PathBuf>,
|
||||
edit_log_session_dir: std::path::PathBuf,
|
||||
session_id: String,
|
||||
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
||||
temperature: f32,
|
||||
max_tokens: u32,
|
||||
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
|
||||
@@ -589,47 +596,57 @@ fn run_agent_turn(
|
||||
};
|
||||
|
||||
let mut stream_started = false;
|
||||
let (response, usage) = match tc.client.chat_with_tools_streaming(
|
||||
let mut usage = None;
|
||||
let result = tc.client.chat_with_tools_streaming(
|
||||
&wire_msgs,
|
||||
Some(tc.tdefs.clone()),
|
||||
Some(tc.temperature),
|
||||
Some(tc.max_tokens),
|
||||
|event| match event {
|
||||
crate::app::runtime::stream::StreamEvent::Token(tok) => {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
if !stream_started {
|
||||
q.push_back(TurnEvent::StreamStart);
|
||||
stream_started = true;
|
||||
|event| -> bool {
|
||||
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
match event {
|
||||
crate::app::runtime::stream::StreamEvent::Token(tok) => {
|
||||
if !stream_started {
|
||||
q.push_back(TurnEvent::StreamStart);
|
||||
stream_started = true;
|
||||
}
|
||||
q.push_back(TurnEvent::StreamToken(tok.clone()));
|
||||
}
|
||||
q.push_back(TurnEvent::StreamToken(tok.clone()));
|
||||
crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
|
||||
usage = Some((*prompt_tokens, *completion_tokens));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Usage {
|
||||
tokens_in: *prompt_tokens,
|
||||
tokens_out: *completion_tokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
true
|
||||
},
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(_stream_err) => {
|
||||
// Provider doesn't support streaming — fall back to non-streaming
|
||||
);
|
||||
|
||||
let (response, final_usage) = match result {
|
||||
Ok((msg, u)) => (msg, u.or(usage)),
|
||||
Err(e) => {
|
||||
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Error("Generation aborted by user".to_string()));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (msg, usage_fb) = tc.client.chat_with_tools_non_streaming(
|
||||
&wire_msgs, Some(tc.tdefs.clone()),
|
||||
)?;
|
||||
if let Some((tok_in, tok_out)) = usage_fb {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out });
|
||||
}
|
||||
}
|
||||
(msg, usage_fb)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((tok_in, tok_out)) = final_usage {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out });
|
||||
}
|
||||
}
|
||||
|
||||
let has_tool_calls = response.tool_calls.is_some()
|
||||
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
||||
|
||||
@@ -640,6 +657,12 @@ fn run_agent_turn(
|
||||
archive_message(&tc.db, &tc.session_id, &response);
|
||||
msgs.push(response);
|
||||
for tool_call in tool_calls {
|
||||
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Error("Turn aborted by user".to_string()));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let tool_name = tool_call.function.name.clone();
|
||||
let args = crate::dto::chat::tool::sanitize_tool_arguments(
|
||||
&tool_call.function.arguments,
|
||||
@@ -650,7 +673,7 @@ fn run_agent_turn(
|
||||
let verdict = crate::app::harness::Harness::gate_tool_call(
|
||||
&tool_name,
|
||||
&args,
|
||||
&tc.mode,
|
||||
|
||||
&ws_roots,
|
||||
);
|
||||
|
||||
@@ -671,7 +694,7 @@ fn run_agent_turn(
|
||||
},
|
||||
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false),
|
||||
Verdict::Escalate => (
|
||||
"Tool requires approval. Switch to Auto mode or provide explicit approval."
|
||||
"Tool requires approval. Provide explicit approval."
|
||||
.to_string(),
|
||||
true,
|
||||
false,
|
||||
|
||||
@@ -43,6 +43,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
message: "transcript cleared".to_string(),
|
||||
}]
|
||||
}
|
||||
Command::Login { provider } if provider.is_empty() => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
message: "Usage: /login <provider>".to_string(),
|
||||
}]
|
||||
}
|
||||
Command::Login { provider } => {
|
||||
vec![Action::StartOAuth { provider }]
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ impl SseParser {
|
||||
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
|
||||
self.buffer = self.buffer[line_end + 1..].to_string();
|
||||
if line.is_empty() {
|
||||
if let Some(event) = self.flush_event() {
|
||||
events.push(event);
|
||||
}
|
||||
events.extend(self.flush_event());
|
||||
} else if let Some(ty) = line.strip_prefix("event: ") {
|
||||
self.event_type = Some(ty.trim().to_string());
|
||||
} else if let Some(data) = line.strip_prefix("data: ") {
|
||||
@@ -59,42 +57,61 @@ impl SseParser {
|
||||
events
|
||||
}
|
||||
|
||||
fn flush_event(&mut self) -> Option<StreamEvent> {
|
||||
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||
let data = self.data_lines.join("\n");
|
||||
self.data_lines.clear();
|
||||
let event_type = self.event_type.take().unwrap_or_default();
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
if data == "[DONE]" {
|
||||
return Some(StreamEvent::Done);
|
||||
return vec![StreamEvent::Done];
|
||||
}
|
||||
return None;
|
||||
return vec![];
|
||||
}
|
||||
let value: Value = serde_json::from_str(&data).ok()?;
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
if let Some(usage) = value.get("usage") {
|
||||
if !usage.is_null() {
|
||||
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64())
|
||||
.unwrap_or(prompt_tokens + completion_tokens);
|
||||
return Some(StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens });
|
||||
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
|
||||
}
|
||||
}
|
||||
match event_type.as_str() {
|
||||
"message.stop" => Some(StreamEvent::Done),
|
||||
"message.start" => None,
|
||||
"message.stop" => vec![StreamEvent::Done],
|
||||
"message.start" => vec![],
|
||||
"message.delta" | "" => {
|
||||
let delta = value.get("delta").or_else(|| value.get("choices"))?;
|
||||
let delta = match value.get("delta").or_else(|| value.get("choices")) {
|
||||
Some(d) => d,
|
||||
None => return vec![],
|
||||
};
|
||||
if let Some(choices) = delta.as_array() {
|
||||
let choice = choices.first()?;
|
||||
let delta = choice.get("delta")?;
|
||||
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
return Some(StreamEvent::Token(content.to_string()));
|
||||
let choice = match choices.first() {
|
||||
Some(c) => c,
|
||||
None => return vec![],
|
||||
};
|
||||
let d = match choice.get("delta") {
|
||||
Some(v) => v,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
// Content token
|
||||
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
||||
return vec![StreamEvent::Token(content.to_string())];
|
||||
}
|
||||
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) {
|
||||
return Some(StreamEvent::Reasoning(reasoning.to_string()));
|
||||
|
||||
// Reasoning token
|
||||
if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) {
|
||||
return vec![StreamEvent::Reasoning(reasoning.to_string())];
|
||||
}
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||
if let Some(tc) = tool_calls.first() {
|
||||
|
||||
// Tool calls — iterate ALL entries, not just first()
|
||||
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||
let mut events = Vec::with_capacity(tool_calls.len());
|
||||
for tc in tool_calls {
|
||||
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
|
||||
let name = tc.get("function")
|
||||
@@ -106,27 +123,31 @@ impl SseParser {
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
return Some(StreamEvent::ToolCallDelta {
|
||||
events.push(StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta: args_delta,
|
||||
});
|
||||
}
|
||||
if !events.is_empty() {
|
||||
return events;
|
||||
}
|
||||
}
|
||||
let finish = choice.get("finish_reason");
|
||||
if let Some(reason) = finish.and_then(|r| r.as_str()) {
|
||||
|
||||
// Finish reason
|
||||
if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
|
||||
if reason == "stop" || reason == "tool_calls" {
|
||||
return Some(StreamEvent::Done);
|
||||
return vec![StreamEvent::Done];
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
return Some(StreamEvent::Token(content.to_string()));
|
||||
return vec![StreamEvent::Token(content.to_string())];
|
||||
}
|
||||
None
|
||||
vec![]
|
||||
}
|
||||
_ => None,
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user