Add provider switch and file mention attachments

This commit is contained in:
Hunter Bown
2026-04-24 23:09:48 -05:00
parent 16f62f7abf
commit 6ad3727fa0
21 changed files with 731 additions and 54 deletions
+2
View File
@@ -110,6 +110,8 @@ deepseek serve --http # HTTP/SSE API server
```
Controls: `F1` help, `Esc` backs out of the current action, `Ctrl+K` command palette.
In the composer, `@path/to/file` adds local text file or directory context to
the next message. Use `/attach <path>` for local image/video media references.
## Configuration
+4 -7
View File
@@ -99,14 +99,11 @@ max_delay = 60.0
exponential_base = 2.0
# ─────────────────────────────────────────────────────────────────────────────────
# Context Compaction (config-level tuning not yet wired; use /set auto_compact on|off)
# Context Compaction
# ─────────────────────────────────────────────────────────────────────────────────
# [compaction]
# enabled = false # Enable auto-compaction
# token_threshold = 50000 # Trigger compaction above this token estimate
# message_threshold = 50 # Or above this message count
# model = "deepseek-v4-flash" # Model to use for summarization
# cache_summary = true # Keep summary blocks stable; DeepSeek context caching is automatic
# Auto-compaction is a saved UI setting edited with `/config` (`auto_compact`).
# There is no config-file `[compaction]` table yet; detailed thresholds are
# chosen by the TUI from the active model/context budget.
# ─────────────────────────────────────────────────────────────────────────────────
# Capacity Controller (runtime pressure guardrails)
+1 -1
View File
@@ -20,7 +20,7 @@ pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult {
let Some(kind) = media_kind(&path) else {
return CommandResult::error(
"Unsupported attachment type. Use an image or video file path.",
"Unsupported attachment type. /attach is for image/video paths; use @path for text files or directories.",
);
};
+1 -1
View File
@@ -95,7 +95,7 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
} else {
let common = COMMON_DEEPSEEK_MODELS.join(", ");
CommandResult::message(format!(
"Current model: {}\nCommon models: {}\nTip: any valid DeepSeek model ID is accepted. Run /models to fetch live IDs from your API endpoint.",
"Current model: {}\nUsage: /model <name>\nCommon models: {}\nTip: any valid DeepSeek model ID is accepted. Run /models to fetch live IDs from your API endpoint.",
app.model, common
))
}
+9 -1
View File
@@ -9,6 +9,7 @@ mod core;
mod debug;
mod init;
mod note;
mod provider;
mod queue;
mod review;
mod session;
@@ -133,6 +134,12 @@ pub const COMMANDS: &[CommandInfo] = &[
description: "List available models from API",
usage: "/models",
},
CommandInfo {
name: "provider",
aliases: &[],
description: "Switch or view the active LLM backend (deepseek | nvidia-nim)",
usage: "/provider [name]",
},
CommandInfo {
name: "queue",
aliases: &["queued"],
@@ -166,7 +173,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "attach",
aliases: &["image", "media"],
description: "Attach a local image or video path to the next message",
description: "Attach image/video media; use @path for text files or directories",
usage: "/attach <path>",
},
CommandInfo {
@@ -329,6 +336,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"exit" | "quit" | "q" => core::exit(),
"model" => core::model(app, arg),
"models" => core::models(app),
"provider" => provider::provider(app, arg),
"queue" | "queued" => queue::queue(app, arg),
"subagents" | "agents" => core::subagents(app),
"links" | "dashboard" | "api" => core::deepseek_links(),
+194
View File
@@ -0,0 +1,194 @@
//! Provider switching: flip between DeepSeek and NVIDIA NIM at runtime.
use crate::config::{ApiProvider, normalize_model_name};
use crate::tui::app::{App, AppAction};
use super::CommandResult;
/// Switch or view the current LLM backend.
///
/// Accepts `<provider> [model]` so you can flip backend and model in one
/// shot, e.g. `/provider nim flash` lands you on
/// `deepseek-ai/deepseek-v4-flash`. The optional model accepts shorthand
/// (`flash`, `pro`, `v4-flash`, `v4-pro`) or any normal DeepSeek model ID.
pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
let trimmed = args.map(str::trim).filter(|s| !s.is_empty());
let Some(args) = trimmed else {
return CommandResult::message(format!(
"Current provider: {}\n\
Active model: {}\n\
Available: deepseek, nvidia-nim\n\
Usage: /provider <name> [model]\n\
Examples: /provider nim flash → NIM v4-flash (recommended)\n\
/provider nim pro → NIM v4-pro (currently DEGRADED)\n\
/provider deepseek → DeepSeek native, default model\n\
Tip: NIM needs NVIDIA_API_KEY (or [providers.nvidia_nim].api_key in config.toml).",
app.api_provider.as_str(),
app.model
));
};
let mut parts = args.split_whitespace();
let name = parts.next().unwrap_or("");
let model_arg = parts.next();
let Some(target) = ApiProvider::parse(name) else {
return CommandResult::error(format!(
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim."
));
};
let model = match model_arg {
None => None,
Some(raw) => match normalize_model_name(&expand_model_alias(raw)) {
Some(normalized) => Some(normalized),
None => {
return CommandResult::error(format!(
"Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro."
));
}
},
};
if target == app.api_provider && model.is_none() {
return CommandResult::message(format!(
"Already on provider: {}",
target.as_str()
));
}
CommandResult::action(AppAction::SwitchProvider {
provider: target,
model,
})
}
fn expand_model_alias(name: &str) -> String {
match name.trim().to_ascii_lowercase().as_str() {
"pro" | "v4-pro" => "deepseek-v4-pro".to_string(),
"flash" | "v4-flash" => "deepseek-v4-flash".to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::TuiOptions;
use std::path::PathBuf;
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn no_args_shows_current_provider_and_usage() {
let mut app = create_test_app();
let result = provider(&mut app, None);
let msg = result.message.expect("expected info message");
assert!(msg.contains("Current provider:"));
assert!(msg.contains("deepseek"));
assert!(msg.contains("Available:"));
assert!(msg.contains("nvidia-nim"));
assert!(msg.contains("/provider nim flash"));
assert!(result.action.is_none());
}
#[test]
fn unknown_provider_returns_error() {
let mut app = create_test_app();
let result = provider(&mut app, Some("openai"));
let msg = result.message.expect("expected error message");
assert!(msg.contains("Unknown provider"));
assert!(result.action.is_none());
}
#[test]
fn switching_to_active_provider_without_model_is_a_noop() {
let mut app = create_test_app();
let result = provider(&mut app, Some("deepseek"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Already on provider"));
assert!(result.action.is_none());
}
#[test]
fn switch_to_nim_emits_action_without_model_override() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nvidia-nim"));
assert!(result.message.is_none());
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::NvidiaNim);
assert_eq!(model, None);
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn nim_flash_shorthand_emits_action_with_model_override() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nim flash"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::NvidiaNim);
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn nim_pro_shorthand_emits_action_with_model_override() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nim pro"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::NvidiaNim);
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn switch_to_active_provider_with_new_model_still_emits_action() {
let mut app = create_test_app();
let result = provider(&mut app, Some("deepseek flash"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::Deepseek);
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn invalid_model_returns_error() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nim gpt-4"));
let msg = result.message.expect("expected error message");
assert!(msg.contains("Invalid model"));
assert!(result.action.is_none());
}
}
+3 -1
View File
@@ -45,7 +45,9 @@ FILE OPERATIONS:
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- web_search: Quick web search (fallback when citations are not needed)
- request_user_input: Ask the user short multiple-choice questions
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
PARALLEL TOOL USE:
- Issue independent tool calls in parallel by emitting multiple tool_calls in one assistant turn (the model API supports this natively). Do not wrap them in any meta-tool or pseudo-XML.
+1 -1
View File
@@ -13,7 +13,7 @@ Tool selection guidance:
- Prefer targeted edits (apply_patch/edit) over full rewrites when possible.
- Use shell tools for build/test/format/lint and other objective verification.
- Use web.run for time-sensitive or uncertain facts; include citations as [cite:ref_id].
- Use multi_tool_use.parallel for multiple read-only tool calls that can run together.
- Issue independent tool calls in parallel (emit multiple tool_calls in a single turn) instead of serializing them.
- Use request_user_input to ask short multiple-choice questions when needed.
+3 -1
View File
@@ -14,7 +14,9 @@ Available tools in this mode:
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- web_search: Quick web search (fallback when citations are not needed)
- request_user_input: Ask the user short multiple-choice questions
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
PARALLEL TOOL USE:
- Issue independent tool calls in parallel by emitting multiple tool_calls in one assistant turn (the model API supports this natively). Do not wrap them in any meta-tool or pseudo-XML.
+3 -1
View File
@@ -30,7 +30,9 @@ EXPLORATION:
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- web_search: Quick web search (fallback when citations are not needed)
- request_user_input: Ask the user short multiple-choice questions
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
PARALLEL TOOL USE:
- Issue independent tool calls in parallel by emitting multiple tool_calls in one assistant turn (the model API supports this natively). Do not wrap them in any meta-tool or pseudo-XML.
+3 -1
View File
@@ -45,7 +45,9 @@ FILE OPERATIONS:
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- web_search: Quick web search (fallback when citations are not needed)
- request_user_input: Ask the user short multiple-choice questions
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
PARALLEL TOOL USE:
- Issue independent tool calls in parallel by emitting multiple tool_calls in one assistant turn (the model API supports this natively). Do not wrap them in any meta-tool or pseudo-XML.
+9
View File
@@ -1,4 +1,12 @@
//! Tool wrapper for executing multiple tool calls in parallel.
//!
//! NOTE: this meta-tool is intentionally no longer registered with the
//! agent (see `ToolRegistryBuilder::with_parallel_tool`). DeepSeek-V4
//! supports native parallel `tool_calls` in a single assistant turn, and
//! advertising the OpenAI-internal name `multi_tool_use.parallel` made
//! the model hallucinate ChatGPT-style XML wrappers. The struct stays
//! around so the engine compatibility dispatcher and historical sessions
//! still resolve it cleanly.
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
@@ -6,6 +14,7 @@ use super::spec::{
use async_trait::async_trait;
use serde_json::{Value, json};
#[allow(dead_code)]
pub struct MultiToolUseParallelTool;
#[async_trait]
+8 -3
View File
@@ -353,11 +353,16 @@ impl ToolRegistryBuilder {
.with_tool(Arc::new(WebRunTool))
}
/// Include multi-tool parallel wrapper.
/// Previously registered the OpenAI-style `multi_tool_use.parallel`
/// meta-tool. DeepSeek-V4 has native parallel tool calls (multiple
/// `tool_calls` entries in one assistant turn) and the meta-tool name
/// triggered the model to hallucinate OpenAI-internal XML wrappers
/// (`<multi_tool_use.parallel><tool_name>…</tool_name>…`) instead of
/// emitting native calls. Kept as a no-op so existing callers compile;
/// the engine's compatibility dispatcher still handles legacy emissions.
#[must_use]
pub fn with_parallel_tool(self) -> Self {
use super::parallel::MultiToolUseParallelTool;
self.with_tool(Arc::new(MultiToolUseParallelTool))
self
}
/// Include request_user_input tool.
-2
View File
@@ -143,7 +143,6 @@ impl SubAgentType {
"file_search",
"web.run",
"web_search",
"multi_tool_use.parallel",
"exec_shell",
"exec_shell_wait",
"exec_shell_interact",
@@ -164,7 +163,6 @@ impl SubAgentType {
"file_search",
"web.run",
"web_search",
"multi_tool_use.parallel",
"exec_shell",
"exec_shell_wait",
"exec_shell_interact",
+15 -1
View File
@@ -9,7 +9,7 @@ use serde_json::Value;
use thiserror::Error;
use crate::compaction::CompactionConfig;
use crate::config::{Config, has_api_key, save_api_key};
use crate::config::{ApiProvider, Config, has_api_key, save_api_key};
use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
use crate::models::{
Message, SystemPrompt, compaction_message_threshold_for_model, compaction_threshold_for_model,
@@ -379,6 +379,10 @@ pub struct App {
/// Last status text already promoted from `status_message` into toast state.
pub last_status_message_seen: Option<String>,
pub model: String,
/// Current API provider (mirrors `Config::api_provider`).
/// Updated by `/provider` switches so the UI/commands can read the
/// active backend without re-deriving it from the live config.
pub api_provider: ApiProvider,
/// Current reasoning-effort tier for DeepSeek thinking mode.
/// Cycled via Shift+Tab; initialized from config at startup.
pub reasoning_effort: ReasoningEffort,
@@ -549,6 +553,7 @@ impl QueuedMessage {
}
}
#[allow(dead_code)] // Tests and queue helpers use the display-only form; send path resolves @mentions.
pub fn content(&self) -> String {
if let Some(skill_instruction) = self.skill_instruction.as_ref() {
format!(
@@ -679,6 +684,7 @@ impl App {
sticky_status: None,
last_status_message_seen: None,
model,
api_provider: config.api_provider(),
reasoning_effort: config
.reasoning_effort()
.map_or_else(ReasoningEffort::default, |s| {
@@ -1418,6 +1424,14 @@ pub enum AppAction {
SendMessage(String),
ListSubAgents,
FetchModels,
/// Switch the active LLM backend (DeepSeek vs NVIDIA NIM) without
/// restarting the process. The runtime rebuilds its API client from
/// the updated config. `model` overrides the post-switch model
/// (already normalized but not yet provider-prefixed).
SwitchProvider {
provider: ApiProvider,
model: Option<String>,
},
UpdateCompaction(CompactionConfig),
CompactContext,
TaskAdd {
+15 -1
View File
@@ -207,7 +207,6 @@ fn command_runs_directly(name: &str) -> bool {
"help"
| "clear"
| "exit"
| "model"
| "models"
| "queue"
| "subagents"
@@ -724,6 +723,21 @@ mod tests {
assert!(!command_labels.contains(&"/deepseek"));
}
#[test]
fn command_palette_inserts_model_command_for_argument_entry() {
let entries = build_entries(Path::new("."), Path::new("."));
let model = entries
.iter()
.find(|entry| entry.section == PaletteSection::Command && entry.label == "/model")
.expect("model command entry");
assert_eq!(model.command, "/model ");
assert!(matches!(
&model.action,
CommandPaletteAction::InsertText { text } if text == "/model "
));
}
#[test]
fn command_palette_emits_actions_not_raw_insertions() {
let entries = vec![CommandPaletteEntry {
+386 -30
View File
@@ -1,7 +1,7 @@
//! TUI event loop and rendering logic for `DeepSeek` CLI.
use std::fmt::Write;
use std::io::{self, Stdout};
use std::io::{self, Read, Stdout};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
@@ -27,9 +27,10 @@ use ratatui::{
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::audit::log_sensitive_event;
use crate::client::DeepSeekClient;
use crate::commands;
use crate::compaction::estimate_input_tokens_conservative;
use crate::config::Config;
use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL};
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
use crate::core::events::Event as EngineEvent;
use crate::core::ops::Op;
@@ -88,6 +89,9 @@ const SLASH_MENU_LIMIT: usize = 6;
const MIN_CHAT_HEIGHT: u16 = 3;
const MIN_COMPOSER_HEIGHT: u16 = 2;
const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 8;
const MAX_MENTION_FILE_BYTES: u64 = 128 * 1024;
const MAX_DIRECTORY_MENTION_ENTRIES: usize = 80;
const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
const UI_IDLE_POLL_MS: u64 = 48;
const UI_ACTIVE_POLL_MS: u64 = 24;
@@ -122,6 +126,10 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
let mut terminal = Terminal::new(backend)?;
let event_broker = EventBroker::new();
// Local mutable copy so runtime config flips (e.g. `/provider` switch)
// can rebuild the API client without restarting the process.
let mut config = config.clone();
let config = &mut config;
let mut app = App::new(options.clone(), config);
// Load existing session if resuming.
@@ -303,7 +311,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
async fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
config: &Config,
config: &mut Config,
mut engine_handle: EngineHandle,
task_manager: SharedTaskManager,
event_broker: &EventBroker,
@@ -830,7 +838,7 @@ async fn run_event_loop(
if !events.is_empty() {
app.needs_redraw = true;
}
if handle_view_events(app, config, &task_manager, &engine_handle, events).await? {
if handle_view_events(app, config, &task_manager, &mut engine_handle, events).await? {
return Ok(());
}
}
@@ -1056,7 +1064,7 @@ async fn run_event_loop(
if !app.view_stack.is_empty() {
let events = app.view_stack.handle_key(key);
if handle_view_events(app, config, &task_manager, &engine_handle, events).await? {
if handle_view_events(app, config, &task_manager, &mut engine_handle, events).await? {
return Ok(());
}
continue;
@@ -1303,7 +1311,7 @@ async fn run_event_loop(
if input.starts_with('/') {
if execute_command_input(
app,
&engine_handle,
&mut engine_handle,
&task_manager,
config,
&input,
@@ -1313,16 +1321,6 @@ async fn run_event_loop(
return Ok(());
}
} 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
@@ -1861,6 +1859,257 @@ fn build_queued_message(app: &mut App, input: String) -> QueuedMessage {
QueuedMessage::new(input, skill_instruction)
}
fn queued_message_content_for_app(app: &App, message: &QueuedMessage) -> String {
let user_request = user_request_with_file_mentions(&message.display, &app.workspace);
if let Some(skill_instruction) = message.skill_instruction.as_ref() {
format!("{skill_instruction}\n\n---\n\nUser request: {user_request}")
} else {
user_request
}
}
fn user_request_with_file_mentions(input: &str, workspace: &Path) -> String {
let Some(context) = local_context_from_file_mentions(input, workspace) else {
return input.to_string();
};
format!("{input}\n\n---\n\nLocal context from @mentions:\n{context}")
}
fn local_context_from_file_mentions(input: &str, workspace: &Path) -> Option<String> {
let mentions = extract_file_mentions(input);
if mentions.is_empty() {
return None;
}
let mut blocks = Vec::new();
let mut seen = std::collections::HashSet::new();
for mention in mentions.into_iter().take(MAX_FILE_MENTIONS_PER_MESSAGE) {
let path = resolve_mention_path(&mention, workspace);
let display_path = path
.canonicalize()
.unwrap_or_else(|_| path.clone())
.display()
.to_string();
if !seen.insert(display_path.clone()) {
continue;
}
blocks.push(render_file_mention_context(&mention, &path, &display_path));
}
if blocks.is_empty() {
None
} else {
Some(blocks.join("\n\n"))
}
}
fn extract_file_mentions(input: &str) -> Vec<String> {
let chars: Vec<char> = input.chars().collect();
let mut mentions = Vec::new();
let mut idx = 0;
while idx < chars.len() {
if chars[idx] != '@' || !is_file_mention_start(&chars, idx) {
idx += 1;
continue;
}
let Some(next) = chars.get(idx + 1).copied() else {
break;
};
if next.is_whitespace() {
idx += 1;
continue;
}
if matches!(next, '"' | '\'') {
let quote = next;
let mut end = idx + 2;
let mut raw = String::new();
while end < chars.len() && chars[end] != quote {
raw.push(chars[end]);
end += 1;
}
if !raw.trim().is_empty() {
mentions.push(raw.trim().to_string());
}
idx = end.saturating_add(1);
continue;
}
let mut end = idx + 1;
let mut raw = String::new();
while end < chars.len() && !chars[end].is_whitespace() {
raw.push(chars[end]);
end += 1;
}
let trimmed = trim_unquoted_mention(&raw);
if !trimmed.is_empty() {
mentions.push(trimmed.to_string());
}
idx = end;
}
mentions
}
fn is_file_mention_start(chars: &[char], idx: usize) -> bool {
if idx == 0 {
return true;
}
chars
.get(idx.saturating_sub(1))
.is_some_and(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\''))
}
fn trim_unquoted_mention(raw: &str) -> &str {
let mut trimmed = raw.trim();
while trimmed.chars().count() > 1
&& trimmed
.chars()
.last()
.is_some_and(|ch| matches!(ch, ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'))
{
trimmed = &trimmed[..trimmed.len() - trimmed.chars().last().unwrap().len_utf8()];
}
trimmed
}
fn resolve_mention_path(raw_path: &str, workspace: &Path) -> PathBuf {
let path = expand_mention_home(raw_path);
if path.is_absolute() {
path
} else {
workspace.join(path)
}
}
fn expand_mention_home(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home);
}
} else if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(rest);
}
PathBuf::from(path)
}
fn render_file_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
if !path.exists() {
return format!("<missing-file mention=\"@{raw}\" path=\"{display_path}\" />");
}
if path.is_dir() {
return render_directory_mention_context(raw, path, display_path);
}
if !path.is_file() {
return format!("<unsupported-path mention=\"@{raw}\" path=\"{display_path}\" />");
}
if is_media_path(path) {
return format!(
"<media-file mention=\"@{raw}\" path=\"{display_path}\">\nUse /attach {raw} when the intent is to attach this image or video to the next message.\n</media-file>"
);
}
match read_text_prefix(path) {
Ok((text, truncated)) => {
let truncated_attr = if truncated { " truncated=\"true\"" } else { "" };
format!(
"<file mention=\"@{raw}\" path=\"{display_path}\"{truncated_attr}>\n{text}\n</file>"
)
}
Err(err) => {
format!(
"<unreadable-file mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-file>"
)
}
}
}
fn render_directory_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
let entries = match std::fs::read_dir(path) {
Ok(entries) => entries,
Err(err) => {
return format!(
"<unreadable-directory mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-directory>"
);
}
};
let mut names = entries
.filter_map(|entry| entry.ok())
.map(|entry| {
let marker = entry
.file_type()
.ok()
.filter(|ty| ty.is_dir())
.map_or("", |_| "/");
format!("{}{}", entry.file_name().to_string_lossy(), marker)
})
.collect::<Vec<_>>();
names.sort();
let total = names.len();
names.truncate(MAX_DIRECTORY_MENTION_ENTRIES);
let mut body = names.join("\n");
if total > MAX_DIRECTORY_MENTION_ENTRIES {
let omitted = total - MAX_DIRECTORY_MENTION_ENTRIES;
let _ = write!(body, "\n... {omitted} more entries");
}
format!("<directory mention=\"@{raw}\" path=\"{display_path}\">\n{body}\n</directory>")
}
fn read_text_prefix(path: &Path) -> std::io::Result<(String, bool)> {
let mut file = std::fs::File::open(path)?;
let mut buffer = Vec::new();
file.by_ref()
.take(MAX_MENTION_FILE_BYTES + 1)
.read_to_end(&mut buffer)?;
let truncated = buffer.len() as u64 > MAX_MENTION_FILE_BYTES;
if truncated {
buffer.truncate(MAX_MENTION_FILE_BYTES as usize);
}
if buffer.contains(&0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"file appears to be binary",
));
}
let text = if truncated {
String::from_utf8_lossy(&buffer).to_string()
} else {
std::str::from_utf8(&buffer)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "file is not UTF-8"))?
.to_string()
};
Ok((text, truncated))
}
fn is_media_path(path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
return false;
};
matches!(
ext.to_ascii_lowercase().as_str(),
"png"
| "jpg"
| "jpeg"
| "gif"
| "webp"
| "bmp"
| "tif"
| "tiff"
| "ppm"
| "mp4"
| "mov"
| "m4v"
| "webm"
| "avi"
| "mkv"
)
}
async fn dispatch_user_message(
app: &mut App,
engine_handle: &EngineHandle,
@@ -1870,7 +2119,7 @@ async fn dispatch_user_message(
app.is_loading = true;
app.last_send_at = Some(Instant::now());
let content = message.content();
let content = queued_message_content_for_app(app, &message);
app.system_prompt = Some(prompts::system_prompt_for_mode_with_context(
app.mode,
&app.workspace,
@@ -1914,6 +2163,114 @@ async fn dispatch_user_message(
Ok(())
}
async fn apply_model_and_compaction_update(
engine_handle: &EngineHandle,
compaction: crate::compaction::CompactionConfig,
) {
let _ = engine_handle
.send(Op::SetModel {
model: compaction.model.clone(),
})
.await;
let _ = engine_handle
.send(Op::SetCompaction { config: compaction })
.await;
}
/// Apply a `/provider` switch by mutating the in-memory config, validating
/// that credentials exist for the new provider, then respawning the engine
/// so the API client picks up the new base URL/key. When `model_override`
/// is set, it replaces the active model post-switch (already normalized,
/// will be provider-prefixed by `Config::default_model`).
async fn switch_provider(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
target: ApiProvider,
model_override: Option<String>,
) {
let previous_provider = app.api_provider;
let previous_model = app.model.clone();
let previous_provider_str = config.provider.clone();
let previous_base_url = config.base_url.clone();
let previous_default_text_model = config.default_text_model.clone();
config.provider = Some(target.as_str().to_string());
if matches!(target, ApiProvider::NvidiaNim)
&& config
.base_url
.as_deref()
.map(|base| !base.contains("integrate.api.nvidia.com"))
.unwrap_or(true)
{
config.base_url = Some(DEFAULT_NVIDIA_NIM_BASE_URL.to_string());
}
if matches!(target, ApiProvider::Deepseek)
&& config
.base_url
.as_deref()
.map(|base| base.contains("integrate.api.nvidia.com"))
.unwrap_or(false)
{
config.base_url = None;
}
if let Some(ref model) = model_override {
config.default_text_model = Some(model.clone());
}
if let Err(err) = DeepSeekClient::new(config) {
config.provider = previous_provider_str;
config.base_url = previous_base_url;
config.default_text_model = previous_default_text_model;
app.add_message(HistoryCell::System {
content: format!(
"Failed to switch provider to {}: {err}\nProvider unchanged ({}).",
target.as_str(),
previous_provider.as_str()
),
});
return;
}
let new_model = config.default_model();
app.api_provider = target;
app.model = new_model.clone();
app.update_model_compaction_budget();
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*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;
}
let _ = engine_handle
.send(Op::SetCompaction {
config: app.compaction_config(),
})
.await;
app.add_message(HistoryCell::System {
content: format!(
"Provider switched: {} → {}\nModel: {} → {}",
previous_provider.as_str(),
target.as_str(),
previous_model,
new_model
),
});
app.status_message = Some(format!("Provider: {}", target.as_str()));
}
fn open_text_pager(app: &mut App, title: String, content: String) {
let width = app
.last_transcript_area
@@ -1928,9 +2285,9 @@ fn open_text_pager(app: &mut App, title: String, content: String) {
async fn apply_command_result(
app: &mut App,
engine_handle: &EngineHandle,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &Config,
config: &mut Config,
result: commands::CommandResult,
) -> Result<bool> {
if let Some(msg) = result.message {
@@ -1997,10 +2354,11 @@ async fn apply_command_result(
}
}
}
AppAction::SwitchProvider { provider, model } => {
switch_provider(app, engine_handle, config, provider, model).await;
}
AppAction::UpdateCompaction(compaction) => {
let _ = engine_handle
.send(Op::SetCompaction { config: compaction })
.await;
apply_model_and_compaction_update(engine_handle, compaction).await;
}
AppAction::OpenConfigView => {
if app.view_stack.top_kind() != Some(ModalKind::Config) {
@@ -2092,9 +2450,9 @@ async fn apply_command_result(
async fn execute_command_input(
app: &mut App,
engine_handle: &EngineHandle,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &Config,
config: &mut Config,
input: &str,
) -> Result<bool> {
let result = commands::execute(input, app);
@@ -2106,7 +2464,7 @@ async fn steer_user_message(
engine_handle: &EngineHandle,
message: QueuedMessage,
) -> Result<()> {
let content = message.content();
let content = queued_message_content_for_app(app, &message);
// Mirror steer input in local transcript/session state.
app.add_message(HistoryCell::User {
@@ -2787,9 +3145,9 @@ fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec<Lin
async fn handle_view_events(
app: &mut App,
config: &Config,
config: &mut Config,
task_manager: &SharedTaskManager,
engine_handle: &EngineHandle,
engine_handle: &mut EngineHandle,
events: Vec<ViewEvent>,
) -> Result<bool> {
for event in events {
@@ -2958,9 +3316,7 @@ async fn handle_view_events(
if let Some(action) = result.action {
match action {
AppAction::UpdateCompaction(compaction) => {
let _ = engine_handle
.send(Op::SetCompaction { config: compaction })
.await;
apply_model_and_compaction_update(engine_handle, compaction).await;
}
AppAction::OpenConfigView => {}
_ => {}
+67
View File
@@ -127,6 +127,73 @@ fn create_test_app() -> App {
App::new(options, &Config::default())
}
#[test]
fn file_mentions_add_local_text_context_to_model_payload() {
let tmpdir = TempDir::new().expect("tempdir");
std::fs::write(
tmpdir.path().join("guide.md"),
"# Guide\nUse the fast path.\n",
)
.expect("write file");
let mut app = create_test_app();
app.workspace = tmpdir.path().to_path_buf();
let message = QueuedMessage::new("Summarize @guide.md".to_string(), None);
let content = queued_message_content_for_app(&app, &message);
assert!(content.starts_with("Summarize @guide.md"));
assert!(content.contains("Local context from @mentions:"));
assert!(content.contains("<file mention=\"@guide.md\""));
assert!(content.contains("# Guide\nUse the fast path."));
assert_eq!(message.display, "Summarize @guide.md");
}
#[test]
fn file_mentions_do_not_trigger_inside_email_addresses() {
let tmpdir = TempDir::new().expect("tempdir");
std::fs::write(tmpdir.path().join("example.com"), "not a mention").expect("write file");
let content = user_request_with_file_mentions("email me@example.com", tmpdir.path());
assert_eq!(content, "email me@example.com");
}
#[test]
fn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {
let tmpdir = TempDir::new().expect("tempdir");
std::fs::write(tmpdir.path().join("photo.png"), b"\0png").expect("write image");
let content = user_request_with_file_mentions("inspect @photo.png", tmpdir.path());
assert!(content.contains("<media-file mention=\"@photo.png\""));
assert!(content.contains("Use /attach photo.png"));
assert!(!content.contains("\0png"));
}
#[tokio::test]
async fn model_change_update_syncs_engine_model_before_compaction() {
let mut app = create_test_app();
app.model = "deepseek-v4-flash".to_string();
let compaction = app.compaction_config();
let mut engine = crate::core::engine::mock_engine_handle();
apply_model_and_compaction_update(&engine.handle, compaction).await;
match engine.rx_op.recv().await.expect("set model op") {
crate::core::ops::Op::SetModel { model } => {
assert_eq!(model, "deepseek-v4-flash");
}
other => panic!("expected SetModel, got {other:?}"),
}
match engine.rx_op.recv().await.expect("set compaction op") {
crate::core::ops::Op::SetCompaction { config } => {
assert_eq!(config.model, "deepseek-v4-flash");
}
other => panic!("expected SetCompaction, got {other:?}"),
}
}
fn init_git_repo() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
+2 -1
View File
@@ -1044,7 +1044,8 @@ impl ModalView for HelpView {
)]),
Line::from(" Ctrl+V - Paste text or attach clipboard image"),
Line::from(" Ctrl+Shift+C - Copy selection (Cmd+C on macOS)"),
Line::from(" /attach <path> - Attach local image/video path"),
Line::from(" @path - Add local text file or directory context"),
Line::from(" /attach <path> - Attach local image/video media path"),
Line::from(""),
Line::from(vec![Span::styled(
"=== Help ===",
+5 -1
View File
@@ -218,7 +218,11 @@ Use `deepseek-tui features list` to inspect known flags and their effective stat
## Local Media Attachments
`Ctrl+V` can attach an image from the clipboard, and `/attach <path>` can attach a local image or video file path to the next message. DeepSeek's public Chat Completions API currently accepts text message content, so attachments are sent as explicit local path references instead of native image/video payloads.
Use `@path/to/file` in the composer to add local text file or directory context
to the next message. Use `/attach <path>` for local image/video media paths, or
`Ctrl+V` to attach an image from the clipboard. DeepSeek's public Chat
Completions API currently accepts text message content, so media attachments are
sent as explicit local path references instead of native image/video payloads.
## Managed Configuration and Requirements
Binary file not shown.