Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- DeepSeek provider (`https://api.deepseek.com`).

### Changed

- `/local add` auto-detects models via `/models` and provides a searchable picker for model selection.
Expand All @@ -16,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Local model overlay background now opaque — no longer shows underlying content.
- Picker search (models, profiles, local models) now matches characters as an ordered subsequence.
- Popup v2 clears before rendering a message such that background text doesn't overlap with popup's message.

## 0.1.4 - 2026-10-4

Expand Down
32 changes: 4 additions & 28 deletions crates/agent/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,21 +147,18 @@ impl AgentMessage {
Self::Assistant(message) => {
let text = message.text();
let calls: Vec<ToolCall> = message.tool_calls().cloned().collect();
// Reasoning history is never re-sent: it is verbose and the
// model does not need its own chain-of-thought back as input.
// The transcript on disk still preserves it.
if calls.is_empty() {
Message::assistant_with_reasoning(
(!text.is_empty()).then_some(text),
None,
Vec::new(),
message.reasoning.clone(),
message.reasoning_details.clone(),
)
} else {
Message::assistant_with_tool_calls_and_reasoning(
(!text.is_empty()).then_some(text),
calls,
None,
Vec::new(),
message.reasoning.clone(),
message.reasoning_details.clone(),
)
}
}
Expand Down Expand Up @@ -242,25 +239,4 @@ mod tests {
);
assert!(message.tool_calls.is_none());
}

#[test]
fn plain_assistant_message_strips_reasoning() {
let assistant = LlmResponse {
content: vec![ContentBlock::Text("hello".into())],
stop_reason: StopReason::Stop,
usage: None,
model: None,
reasoning: Some("thought process".into()),
reasoning_details: vec![
serde_json::json!({"type": "reasoning.text", "text": "thought process"}),
],
};

let message = AgentMessage::Assistant(assistant).to_llm();

assert_eq!(message.content.as_deref(), Some("hello"));
assert_eq!(message.reasoning, None);
assert!(message.reasoning_details.is_none());
assert!(message.tool_calls.is_none());
}
}
5 changes: 3 additions & 2 deletions crates/alan/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ use agent::{Agent, SessionManager, ToolPermissionManager, default_tools};
use alan_tui::Runtime;
use llm::ReasoningEffort;
use providers::{
FileCredentialStore, LocalProvider, ModelOptions, OpenRouterProvider, Provider,
ProviderRegistry, ZaiProvider, bind_model,
DeepSeekProvider, FileCredentialStore, LocalProvider, ModelOptions, OpenRouterProvider,
Provider, ProviderRegistry, ZaiProvider, bind_model,
};
use std::path::PathBuf;
use std::sync::Arc;
Expand Down Expand Up @@ -154,6 +154,7 @@ fn build_providers(
// until there really exists some evidence that this is needed, we can add back
// Arc::new(GoogleProvider::from_store(Arc::clone(credential_store)).build()?),
Arc::new(OpenRouterProvider::from_store(Arc::clone(credential_store)).build()?),
Arc::new(DeepSeekProvider::from_store(Arc::clone(credential_store)).build()?),
Arc::clone(local_provider) as Arc<dyn Provider>,
])
}
Expand Down
1 change: 1 addition & 0 deletions crates/alan/src/views/components/popup_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ impl PopupListv2 {
}

if let Some(message) = &self.message {
frame.render_widget(ratatui::widgets::Clear, area);
frame.render_widget(
Paragraph::new(message.clone())
.style(Style::default().bg(theme::EDITOR_BG))
Expand Down
31 changes: 28 additions & 3 deletions crates/alan/src/views/components/search_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ pub struct SearchListOverlay {
selected: usize,
}

fn is_subsequence(query: &str, item: &str) -> bool {
let query = query.to_lowercase();
if query.is_empty() {
return true;
}
let mut want = query.chars();
let mut next = want.next();
for c in item.to_lowercase().chars() {
if Some(c) == next {
next = want.next();
if next.is_none() {
return true;
}
}
}
next.is_none()
}

impl SearchListOverlay {
pub fn new(title: impl Into<String>, items: Vec<String>) -> Self {
let filtered = (0..items.len()).collect();
Expand All @@ -50,12 +68,11 @@ impl SearchListOverlay {
}

fn refresh(&mut self) {
let query = self.query.to_lowercase();
self.filtered = self
.items
.iter()
.enumerate()
.filter(|(_, item)| query.is_empty() || item.to_lowercase().contains(&query))
.filter(|(_, item)| is_subsequence(&self.query, item))
.map(|(index, _)| index)
.collect();
self.selected = self.selected.min(self.filtered.len().saturating_sub(1));
Expand Down Expand Up @@ -112,6 +129,14 @@ impl Component<AlanAction> for SearchListOverlay {
cx.close_overlay();
}
}
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => {
self.query.clear();
self.refresh();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.query.clear();
self.refresh();
}
KeyCode::Backspace => {
self.query.pop();
self.refresh();
Expand Down Expand Up @@ -164,7 +189,7 @@ impl SearchListOverlay {
.title_alignment(Alignment::Center)
.title_bottom(
Line::from(if width >= 60 {
" ↑↓ / C-n C-p move Enter select Esc close "
" ↑↓ / C-n C-p move Enter select C-⌫ clear Esc close "
} else if width >= 38 {
" ↑↓ move · Enter select · Esc close "
} else {
Expand Down
14 changes: 6 additions & 8 deletions crates/llm/src/apis/chat_completions/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,6 @@ pub struct WireMessage {
pub tool_calls: Option<Vec<WireToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Always `None`: reasoning history is never re-sent on the wire
/// (OpenAI-compatible APIs reject or mis-handle round-tripped
/// `reasoning_details`).
pub reasoning_details: Option<Vec<serde_json::Value>>,
}

#[derive(Serialize)]
Expand Down Expand Up @@ -367,7 +362,6 @@ fn wire_message(message: &Message) -> WireMessage {
.collect()
}),
tool_call_id: message.tool_call_id.clone(),
reasoning_details: None,
}
}

Expand Down Expand Up @@ -450,7 +444,7 @@ mod tests {
}

#[test]
fn serializes_reasoning_effort_and_strips_reasoning_details() {
fn serializes_reasoning_effort_and_omits_reasoning_history() {
let messages = [Message::assistant_with_tool_calls_and_reasoning(
None,
vec![crate::ToolCall {
Expand All @@ -469,7 +463,11 @@ mod tests {
serde_json::from_str(&codec().request(&request).unwrap()).unwrap();
assert_eq!(json["reasoning"]["effort"], "high");
assert!(
json["messages"][0].get("reasoning_details").is_none(),
json["messages"][0]
.as_object()
.unwrap()
.keys()
.all(|key| key != "reasoning_details" && key != "reasoning_content"),
"reasoning history must not be re-sent"
);
}
Expand Down
Loading
Loading