fix: close v0.7.2 issue cleanup
This commit is contained in:
@@ -64,6 +64,16 @@ deepseek --provider nvidia-nim
|
||||
DEEPSEEK_PROVIDER=nvidia-nim NVIDIA_API_KEY="..." deepseek
|
||||
```
|
||||
|
||||
### Other DeepSeek V4 providers
|
||||
|
||||
```bash
|
||||
deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"
|
||||
deepseek --provider fireworks --model deepseek-v4-pro
|
||||
|
||||
# SGLang is self-hosted; auth is optional for localhost deployments.
|
||||
SGLANG_BASE_URL="http://localhost:30000/v1" deepseek --provider sglang --model deepseek-v4-flash
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Install from source</summary>
|
||||
|
||||
@@ -181,9 +191,12 @@ Key environment overrides:
|
||||
| `DEEPSEEK_API_KEY` | API key |
|
||||
| `DEEPSEEK_BASE_URL` | API base URL |
|
||||
| `DEEPSEEK_MODEL` | Default model |
|
||||
| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default) or `nvidia-nim` |
|
||||
| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default), `nvidia-nim`, `fireworks`, or `sglang` |
|
||||
| `DEEPSEEK_PROFILE` | Config profile name |
|
||||
| `NVIDIA_API_KEY` | NVIDIA NIM API key |
|
||||
| `FIREWORKS_API_KEY` | Fireworks AI API key |
|
||||
| `SGLANG_BASE_URL` | Self-hosted SGLang endpoint |
|
||||
| `SGLANG_API_KEY` | Optional SGLang bearer token |
|
||||
|
||||
Quick diagnostics:
|
||||
|
||||
|
||||
+22
-5
@@ -10,12 +10,12 @@
|
||||
# Active provider + DeepSeek defaults
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Choose which provider to use by default. Per-provider credentials live in the
|
||||
# `[providers.deepseek]` and `[providers.nvidia_nim]` sections near the bottom of
|
||||
# `[providers.*]` sections near the bottom of
|
||||
# this file — keeping both stored at once means `/provider deepseek` and
|
||||
# `/provider nvidia-nim` (or `--provider nvidia-nim`) toggle without having to
|
||||
# `/provider nvidia-nim` (or `--provider fireworks`, `/provider sglang`) toggle without having to
|
||||
# re-enter keys. Top-level `api_key` / `base_url` are still read as DeepSeek
|
||||
# defaults when `[providers.deepseek]` is absent (backward compatibility).
|
||||
provider = "deepseek" # deepseek | nvidia-nim
|
||||
provider = "deepseek" # deepseek | nvidia-nim | openrouter | novita | fireworks | sglang
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
|
||||
base_url = "https://api.deepseek.com"
|
||||
# base_url = "https://api.deepseeki.com" # China users
|
||||
@@ -29,6 +29,9 @@ base_url = "https://api.deepseek.com"
|
||||
# deepseek-v4-flash — fast, cost-efficient (legacy aliases: deepseek-chat, deepseek-reasoner)
|
||||
# deepseek-ai/deepseek-v4-pro — NVIDIA NIM-hosted Pro model ID
|
||||
# deepseek-ai/deepseek-v4-flash — NVIDIA NIM-hosted Flash model ID
|
||||
# accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID
|
||||
# deepseek-ai/DeepSeek-V4-Pro — SGLang self-hosted Pro model ID
|
||||
# deepseek-ai/DeepSeek-V4-Flash — SGLang self-hosted Flash model ID
|
||||
default_text_model = "deepseek-v4-pro"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -81,12 +84,14 @@ max_subagents = 5 # optional (1-20)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Per-provider credentials (peer providers — NIM is first-class, not a flag)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Both providers can be stored at once; `provider = "..."` (top of file) or
|
||||
# `/provider deepseek` / `/provider nvidia-nim` switches between them without
|
||||
# Providers can be stored at once; `provider = "..."` (top of file) or
|
||||
# `/provider deepseek` / `/provider nvidia-nim` / `/provider fireworks` switches between them without
|
||||
# having to re-enter keys. Env vars override anything set here:
|
||||
# DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL
|
||||
# NIM: NVIDIA_API_KEY (or NVIDIA_NIM_API_KEY), NIM_BASE_URL
|
||||
# (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), NVIDIA_NIM_MODEL
|
||||
# Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL
|
||||
# SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY
|
||||
|
||||
# DeepSeek Platform (https://platform.deepseek.com)
|
||||
[providers.deepseek]
|
||||
@@ -100,6 +105,18 @@ max_subagents = 5 # optional (1-20)
|
||||
# base_url = "https://integrate.api.nvidia.com/v1"
|
||||
# model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash
|
||||
|
||||
# Fireworks AI-hosted DeepSeek V4 (https://fireworks.ai)
|
||||
[providers.fireworks]
|
||||
# api_key = "YOUR_FIREWORKS_API_KEY"
|
||||
# base_url = "https://api.fireworks.ai/inference/v1"
|
||||
# model = "accounts/fireworks/models/deepseek-v4-pro"
|
||||
|
||||
# Self-hosted SGLang OpenAI-compatible server
|
||||
[providers.sglang]
|
||||
# api_key = "OPTIONAL_SGLANG_TOKEN"
|
||||
# base_url = "http://localhost:30000/v1"
|
||||
# model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Network Policy (#135)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -447,6 +447,17 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_str_reports_provided_fields_on_missing_required_field() {
|
||||
let input = json!({"path": "src/lib.rs", "content": "new body"});
|
||||
let err = required_str(&input, "replace").expect_err("replace is missing");
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("missing required field 'replace'"));
|
||||
assert!(message.contains("Input provided:"));
|
||||
assert!(message.contains("path"));
|
||||
assert!(message.contains("content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_error_display_matches_legacy_text() {
|
||||
let err = ToolError::missing_field("path");
|
||||
|
||||
@@ -448,10 +448,12 @@ impl DeepSeekClient {
|
||||
fn build_http_client(api_key: &str) -> Result<reqwest::Client> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {api_key}"))?,
|
||||
);
|
||||
if !api_key.trim().is_empty() {
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {api_key}"))?,
|
||||
);
|
||||
}
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Provider switching: flip between DeepSeek, NVIDIA NIM, OpenRouter, and
|
||||
//! Novita AI at runtime.
|
||||
//! Provider switching: flip between DeepSeek, hosted providers, and self-hosted
|
||||
//! OpenAI-compatible DeepSeek V4 servers at runtime.
|
||||
//!
|
||||
//! `/provider` with no args opens the picker modal (#52). `/provider <name>`
|
||||
//! keeps the v0.6.6 CLI form for muscle-memory + scripted use.
|
||||
@@ -27,7 +27,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
|
||||
let Some(target) = ApiProvider::parse(name) else {
|
||||
return CommandResult::error(format!(
|
||||
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita."
|
||||
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita, fireworks, or sglang."
|
||||
));
|
||||
};
|
||||
|
||||
@@ -135,6 +135,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_fireworks_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("fireworks pro"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::Fireworks);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_sglang_flash_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("sglang flash"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::Sglang);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_active_provider_without_model_is_a_noop() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -1020,11 +1020,10 @@ impl Config {
|
||||
"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \
|
||||
set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml."
|
||||
),
|
||||
ApiProvider::Sglang => anyhow::bail!(
|
||||
"SGLang API key not found (optional for self-hosted). Run 'deepseek auth set --provider sglang', \
|
||||
set SGLANG_API_KEY, or add [providers.sglang] api_key in ~/.deepseek/config.toml. \
|
||||
If your SGLang deployment runs without authentication, set SGLANG_API_KEY to an empty string or any placeholder."
|
||||
),
|
||||
// Self-hosted SGLang deployments commonly run without auth on
|
||||
// localhost. Return an empty key and let the client omit the
|
||||
// Authorization header.
|
||||
ApiProvider::Sglang => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2715,6 +2714,60 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fireworks_provider_uses_canonical_defaults() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-fireworks-defaults-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let config = Config {
|
||||
provider: Some("fireworks".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
config.validate()?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::Fireworks);
|
||||
assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);
|
||||
assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sglang_provider_works_without_api_key() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-sglang-defaults-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let config = Config {
|
||||
provider: Some("sglang".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
config.validate()?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::Sglang);
|
||||
assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);
|
||||
assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);
|
||||
assert_eq!(config.deepseek_api_key()?, "");
|
||||
assert!(has_api_key_for(&config, ApiProvider::Sglang));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
@@ -2880,6 +2933,10 @@ api_key = "novita-table-key"
|
||||
|
||||
let mut config = Config::default();
|
||||
assert!(!has_api_key_for(&config, ApiProvider::Openrouter));
|
||||
assert!(
|
||||
has_api_key_for(&config, ApiProvider::Sglang),
|
||||
"SGLang is self-hosted and does not require a key by default"
|
||||
);
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
@@ -2946,6 +3003,26 @@ api_key = "novita-table-key"
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("novita-saved-key")
|
||||
);
|
||||
save_api_key_for(ApiProvider::Fireworks, "fireworks-saved-key")?;
|
||||
save_api_key_for(ApiProvider::Sglang, "sglang-saved-key")?;
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
let parsed: toml::Value = toml::from_str(&contents)?;
|
||||
assert_eq!(
|
||||
parsed
|
||||
.get("providers")
|
||||
.and_then(|p| p.get("fireworks"))
|
||||
.and_then(|t| t.get("api_key"))
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("fireworks-saved-key")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed
|
||||
.get("providers")
|
||||
.and_then(|p| p.get("sglang"))
|
||||
.and_then(|t| t.get("api_key"))
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("sglang-saved-key")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ pub mod session_picker;
|
||||
pub mod sidebar;
|
||||
pub mod slash_menu;
|
||||
pub mod streaming;
|
||||
mod subagent_routing;
|
||||
mod tool_routing;
|
||||
pub mod transcript;
|
||||
pub mod transcript_cache;
|
||||
pub mod ui;
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Sub-agent and background-task routing helpers for the TUI loop.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::task_manager::{TaskRecord, TaskStatus, TaskSummary};
|
||||
use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus};
|
||||
use crate::tui::app::{App, AppMode, TaskPanelEntry};
|
||||
use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
|
||||
use crate::tui::pager::PagerView;
|
||||
use crate::tui::widgets::agent_card::{
|
||||
AgentLifecycle, DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout,
|
||||
};
|
||||
|
||||
pub(super) fn running_agent_count(app: &App) -> usize {
|
||||
let mut ids: std::collections::HashSet<&str> =
|
||||
app.agent_progress.keys().map(String::as_str).collect();
|
||||
for agent in app
|
||||
.subagent_cache
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
|
||||
{
|
||||
ids.insert(agent.agent_id.as_str());
|
||||
}
|
||||
ids.len()
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_subagent_activity_state(app: &mut App) {
|
||||
let running_agents: Vec<(String, String)> = app
|
||||
.subagent_cache
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
|
||||
.map(|agent| {
|
||||
(
|
||||
agent.agent_id.clone(),
|
||||
summarize_tool_output(&agent.assignment.objective),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let running_ids: std::collections::HashSet<String> =
|
||||
running_agents.iter().map(|(id, _)| id.clone()).collect();
|
||||
app.agent_progress
|
||||
.retain(|id, _| running_ids.contains(id.as_str()));
|
||||
for (id, objective) in running_agents {
|
||||
app.agent_progress.entry(id).or_insert(objective);
|
||||
}
|
||||
|
||||
if running_ids.is_empty() {
|
||||
app.agent_activity_started_at = None;
|
||||
} else if app.agent_activity_started_at.is_none() {
|
||||
app.agent_activity_started_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
fn subagent_status_rank(status: &SubAgentStatus) -> u8 {
|
||||
match status {
|
||||
SubAgentStatus::Running => 0,
|
||||
SubAgentStatus::Interrupted(_) => 1,
|
||||
SubAgentStatus::Failed(_) => 2,
|
||||
SubAgentStatus::Completed => 3,
|
||||
SubAgentStatus::Cancelled => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sort_subagents_in_place(agents: &mut [SubAgentResult]) {
|
||||
agents.sort_by(|a, b| {
|
||||
subagent_status_rank(&a.status)
|
||||
.cmp(&subagent_status_rank(&b.status))
|
||||
.then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str()))
|
||||
.then_with(|| a.agent_id.cmp(&b.agent_id))
|
||||
});
|
||||
}
|
||||
|
||||
/// Route a `MailboxMessage` envelope to the matching in-transcript card,
|
||||
/// allocating a `DelegateCard` or `FanoutCard` on first sight (issue #128).
|
||||
pub(super) fn handle_subagent_mailbox(app: &mut App, _seq: u64, message: &MailboxMessage) {
|
||||
// Accumulate sub-agent token costs for the real-time footer counter (#166).
|
||||
if let MailboxMessage::TokenUsage { model, usage, .. } = message {
|
||||
if let Some(cost) = crate::pricing::calculate_turn_cost_from_usage(model, usage) {
|
||||
app.subagent_cost += cost;
|
||||
}
|
||||
return; // No card visual change needed; the footer handles display.
|
||||
}
|
||||
|
||||
// Resolve (or allocate) the target cell for this envelope. ChildSpawned
|
||||
// is special — it always belongs to the active fanout card if one
|
||||
// exists; otherwise it seeds a new one.
|
||||
let agent_id = message.agent_id().to_string();
|
||||
|
||||
if matches!(message, MailboxMessage::ChildSpawned { .. })
|
||||
&& let Some(idx) = app.last_fanout_card_index
|
||||
&& let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx)
|
||||
{
|
||||
apply_to_fanout(card, message);
|
||||
app.subagent_card_index.insert(agent_id, idx);
|
||||
app.mark_history_updated();
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing card for this agent_id? Mutate in place.
|
||||
if let Some(&idx) = app.subagent_card_index.get(&agent_id) {
|
||||
let updated = match app.history.get_mut(idx) {
|
||||
Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => {
|
||||
apply_to_delegate(card, message)
|
||||
}
|
||||
Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => {
|
||||
apply_to_fanout(card, message)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if updated {
|
||||
app.mark_history_updated();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No existing card — only `Started` reasonably opens one. Anything else
|
||||
// for an unknown agent_id is dropped (likely arrived after the cell was
|
||||
// cleared, e.g. session-resume edge cases).
|
||||
let MailboxMessage::Started { agent_type, .. } = message else {
|
||||
return;
|
||||
};
|
||||
|
||||
let dispatch_kind = app.pending_subagent_dispatch.as_deref();
|
||||
let is_fanout = matches!(
|
||||
dispatch_kind,
|
||||
Some("agent_swarm" | "spawn_agents_on_csv" | "rlm")
|
||||
);
|
||||
|
||||
if is_fanout {
|
||||
// Reuse the active fanout card for sibling spawns; otherwise create
|
||||
// one anchored at this position so subsequent siblings join it.
|
||||
if let Some(idx) = app.last_fanout_card_index
|
||||
&& let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) =
|
||||
app.history.get_mut(idx)
|
||||
{
|
||||
card.upsert_worker(&agent_id, AgentLifecycle::Running);
|
||||
app.subagent_card_index.insert(agent_id, idx);
|
||||
} else {
|
||||
let mut card = FanoutCard::new(dispatch_kind.unwrap_or("fanout").to_string());
|
||||
card.upsert_worker(&agent_id, AgentLifecycle::Running);
|
||||
app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
|
||||
let idx = app.history.len().saturating_sub(1);
|
||||
app.last_fanout_card_index = Some(idx);
|
||||
app.subagent_card_index.insert(agent_id, idx);
|
||||
}
|
||||
} else {
|
||||
let card = DelegateCard::new(agent_id.clone(), agent_type.clone());
|
||||
app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card)));
|
||||
let idx = app.history.len().saturating_sub(1);
|
||||
app.subagent_card_index.insert(agent_id, idx);
|
||||
// Single delegate consumes the pending dispatch label so a follow-on
|
||||
// tool call doesn't accidentally inherit it.
|
||||
app.pending_subagent_dispatch = None;
|
||||
}
|
||||
|
||||
app.mark_history_updated();
|
||||
}
|
||||
|
||||
pub(super) fn task_mode_label(mode: AppMode) -> &'static str {
|
||||
mode.as_setting()
|
||||
}
|
||||
|
||||
pub(super) fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry {
|
||||
TaskPanelEntry {
|
||||
id: summary.id,
|
||||
status: task_status_label(summary.status).to_string(),
|
||||
prompt_summary: summary.prompt_summary,
|
||||
duration_ms: summary.duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn task_status_label(status: TaskStatus) -> &'static str {
|
||||
match status {
|
||||
TaskStatus::Queued => "queued",
|
||||
TaskStatus::Running => "running",
|
||||
TaskStatus::Completed => "completed",
|
||||
TaskStatus::Failed => "failed",
|
||||
TaskStatus::Canceled => "canceled",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn format_task_list(tasks: &[TaskSummary]) -> String {
|
||||
if tasks.is_empty() {
|
||||
return "No tasks found.".to_string();
|
||||
}
|
||||
|
||||
let mut lines = vec![
|
||||
format!("Tasks ({})", tasks.len()),
|
||||
"----------------------------------------".to_string(),
|
||||
];
|
||||
for task in tasks {
|
||||
let duration = task
|
||||
.duration_ms
|
||||
.map(|ms| format!("{:.2}s", ms as f64 / 1000.0))
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
lines.push(format!(
|
||||
"{} {:9} {} {}",
|
||||
task.id,
|
||||
task_status_label(task.status),
|
||||
duration,
|
||||
task.prompt_summary
|
||||
));
|
||||
}
|
||||
lines.push("Use /task show <id> for timeline details.".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub(super) fn open_task_pager(app: &mut App, task: &TaskRecord) {
|
||||
let width = app
|
||||
.last_transcript_area
|
||||
.map(|area| area.width)
|
||||
.unwrap_or(100)
|
||||
.saturating_sub(4);
|
||||
app.view_stack.push(PagerView::from_text(
|
||||
format!("Task {}", task.id),
|
||||
&format_task_detail(task),
|
||||
width.max(60),
|
||||
));
|
||||
}
|
||||
|
||||
fn format_task_detail(task: &TaskRecord) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("Task: {}", task.id));
|
||||
lines.push(format!("Status: {}", task_status_label(task.status)));
|
||||
lines.push(format!("Mode: {}", task.mode));
|
||||
lines.push(format!("Model: {}", task.model));
|
||||
lines.push(format!("Workspace: {}", task.workspace.display()));
|
||||
if let Some(thread_id) = task.thread_id.as_ref() {
|
||||
lines.push(format!("Runtime Thread: {thread_id}"));
|
||||
}
|
||||
if let Some(turn_id) = task.turn_id.as_ref() {
|
||||
lines.push(format!("Runtime Turn: {turn_id}"));
|
||||
}
|
||||
if task.runtime_event_count > 0 {
|
||||
lines.push(format!("Runtime Events: {}", task.runtime_event_count));
|
||||
}
|
||||
lines.push(format!("Created: {}", task.created_at));
|
||||
if let Some(started_at) = task.started_at {
|
||||
lines.push(format!("Started: {}", started_at));
|
||||
}
|
||||
if let Some(ended_at) = task.ended_at {
|
||||
lines.push(format!("Ended: {}", ended_at));
|
||||
}
|
||||
if let Some(duration) = task.duration_ms {
|
||||
lines.push(format!("Duration: {:.2}s", duration as f64 / 1000.0));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("Prompt:".to_string());
|
||||
lines.push(task.prompt.clone());
|
||||
|
||||
if let Some(summary) = task.result_summary.as_ref() {
|
||||
lines.push(String::new());
|
||||
lines.push("Result Summary:".to_string());
|
||||
lines.push(summary.clone());
|
||||
}
|
||||
if let Some(path) = task.result_detail_path.as_ref() {
|
||||
lines.push(format!("Result Artifact: {}", path.display()));
|
||||
}
|
||||
if let Some(error) = task.error.as_ref() {
|
||||
lines.push(String::new());
|
||||
lines.push(format!("Error: {error}"));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push("Tool Calls:".to_string());
|
||||
if task.tool_calls.is_empty() {
|
||||
lines.push("- (none)".to_string());
|
||||
} else {
|
||||
for tool in &task.tool_calls {
|
||||
let status = match tool.status {
|
||||
crate::task_manager::TaskToolStatus::Running => "running",
|
||||
crate::task_manager::TaskToolStatus::Success => "success",
|
||||
crate::task_manager::TaskToolStatus::Failed => "failed",
|
||||
crate::task_manager::TaskToolStatus::Canceled => "canceled",
|
||||
};
|
||||
let mut line = format!(
|
||||
"- {} [{}] {}",
|
||||
tool.name,
|
||||
status,
|
||||
tool.output_summary.as_deref().unwrap_or("(no summary)")
|
||||
);
|
||||
if let Some(duration) = tool.duration_ms {
|
||||
line.push_str(&format!(" ({:.2}s)", duration as f64 / 1000.0));
|
||||
}
|
||||
lines.push(line);
|
||||
if let Some(path) = tool.detail_path.as_ref() {
|
||||
lines.push(format!(" detail: {}", path.display()));
|
||||
}
|
||||
if let Some(path) = tool.patch_ref.as_ref() {
|
||||
lines.push(format!(" patch: {}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push("Timeline:".to_string());
|
||||
if task.timeline.is_empty() {
|
||||
lines.push("- (none)".to_string());
|
||||
} else {
|
||||
for entry in &task.timeline {
|
||||
lines.push(format!(
|
||||
"- [{}] {}: {}",
|
||||
entry.timestamp, entry.kind, entry.summary
|
||||
));
|
||||
if let Some(path) = entry.detail_path.as_ref() {
|
||||
lines.push(format!(" detail: {}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
//! Active tool-card routing helpers for the TUI loop.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::tools::ReviewOutput;
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
use crate::tui::active_cell::ActiveCell;
|
||||
use crate::tui::app::{App, ToolDetailRecord};
|
||||
use crate::tui::history::{
|
||||
DiffPreviewCell, ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell,
|
||||
McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus,
|
||||
ViewImageCell, WebSearchCell, summarize_mcp_output, summarize_tool_args, summarize_tool_output,
|
||||
};
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(super) fn handle_tool_call_started(
|
||||
app: &mut App,
|
||||
id: &str,
|
||||
name: &str,
|
||||
input: &serde_json::Value,
|
||||
) {
|
||||
let id = id.to_string();
|
||||
|
||||
// All in-flight tool work for the current turn lives in `app.active_cell`
|
||||
// until the turn completes. This mirrors Codex's contract: ONE active cell
|
||||
// mutates in place; finalized history isn't touched until flush. This
|
||||
// keeps the transcript stable while parallel completions arrive in any
|
||||
// order.
|
||||
if app.active_cell.is_none() {
|
||||
app.active_cell = Some(ActiveCell::new());
|
||||
}
|
||||
|
||||
if is_exploring_tool(name) {
|
||||
let label = exploring_label(name, input);
|
||||
// ensure_exploring + append_to_exploring keeps all parallel exploring
|
||||
// starts in a single ExploringCell entry.
|
||||
let active = app.active_cell.as_mut().expect("active_cell just ensured");
|
||||
let entry_idx = active.ensure_exploring();
|
||||
let inner = active
|
||||
.append_to_exploring(
|
||||
id.clone(),
|
||||
ExploringEntry {
|
||||
label,
|
||||
status: ToolStatus::Running,
|
||||
},
|
||||
)
|
||||
.map_or(0, |(_, inner)| inner);
|
||||
app.exploring_cell = Some(entry_idx);
|
||||
let virtual_index = app.history.len() + entry_idx;
|
||||
app.exploring_entries
|
||||
.insert(id.clone(), (virtual_index, inner));
|
||||
register_tool_cell(app, &id, name, input, virtual_index);
|
||||
app.mark_history_updated();
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-exploring tool: each is its own entry inside the active cell. We
|
||||
// intentionally do NOT clear `exploring_cell` here — the active cell can
|
||||
// hold both an exploring aggregate AND independent tool entries
|
||||
// simultaneously, which is exactly the case CX#7 fixes.
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
name,
|
||||
input,
|
||||
HistoryCell::Tool(ToolCell::Exec(ExecCell {
|
||||
command,
|
||||
status: ToolStatus::Running,
|
||||
output: None,
|
||||
started_at: Some(Instant::now()),
|
||||
duration_ms: None,
|
||||
source,
|
||||
interaction: Some(summary.clone()),
|
||||
})),
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
name,
|
||||
input,
|
||||
HistoryCell::Tool(ToolCell::Exec(ExecCell {
|
||||
command,
|
||||
status: ToolStatus::Running,
|
||||
output: None,
|
||||
started_at: Some(Instant::now()),
|
||||
duration_ms: None,
|
||||
source,
|
||||
interaction: None,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if name == "update_plan" {
|
||||
let (explanation, steps) = parse_plan_input(input);
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
name,
|
||||
input,
|
||||
HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
|
||||
explanation,
|
||||
steps,
|
||||
status: ToolStatus::Running,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if name == "apply_patch" {
|
||||
let (path, summary) = parse_patch_summary(input);
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
name,
|
||||
input,
|
||||
HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell {
|
||||
path,
|
||||
summary,
|
||||
status: ToolStatus::Running,
|
||||
error: None,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if name == "review" {
|
||||
let target = review_target_label(input);
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
name,
|
||||
input,
|
||||
HistoryCell::Tool(ToolCell::Review(ReviewCell {
|
||||
target,
|
||||
status: ToolStatus::Running,
|
||||
output: None,
|
||||
error: None,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if is_mcp_tool(name) {
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
name,
|
||||
input,
|
||||
HistoryCell::Tool(ToolCell::Mcp(McpToolCell {
|
||||
tool: name.to_string(),
|
||||
status: ToolStatus::Running,
|
||||
content: None,
|
||||
is_image: false,
|
||||
})),
|
||||
);
|
||||
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();
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
name,
|
||||
input,
|
||||
HistoryCell::Tool(ToolCell::ViewImage(ViewImageCell { path: display_path })),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if is_web_search_tool(name) {
|
||||
let query = web_search_query(input);
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
name,
|
||||
input,
|
||||
HistoryCell::Tool(ToolCell::WebSearch(WebSearchCell {
|
||||
query,
|
||||
status: ToolStatus::Running,
|
||||
summary: None,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let input_summary = summarize_tool_args(input);
|
||||
let prompts = extract_fanout_prompts(name, input);
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
name,
|
||||
input,
|
||||
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: name.to_string(),
|
||||
status: ToolStatus::Running,
|
||||
input_summary,
|
||||
output: None,
|
||||
prompts,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract per-child prompts from a fan-out tool's input. Currently no
|
||||
/// top-level tool exposes a prompt list — fan-out lives inside the RLM
|
||||
/// REPL via `llm_query_batched`. Kept as a stable hook for any future
|
||||
/// fan-out tool we add.
|
||||
fn extract_fanout_prompts(_name: &str, _input: &serde_json::Value) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Push a tool cell as a new entry in `active_cell`, register the tool id,
|
||||
/// and write a stub detail record so the pager / Ctrl+O can find it.
|
||||
fn push_active_tool_cell(
|
||||
app: &mut App,
|
||||
tool_id: &str,
|
||||
tool_name: &str,
|
||||
input: &serde_json::Value,
|
||||
cell: HistoryCell,
|
||||
) {
|
||||
if app.active_cell.is_none() {
|
||||
app.active_cell = Some(ActiveCell::new());
|
||||
}
|
||||
let active = app.active_cell.as_mut().expect("active_cell just ensured");
|
||||
let entry_idx = active.push_tool(tool_id.to_string(), cell);
|
||||
let virtual_index = app.history.len() + entry_idx;
|
||||
register_tool_cell(app, tool_id, tool_name, input, virtual_index);
|
||||
app.mark_history_updated();
|
||||
}
|
||||
|
||||
fn register_tool_cell(
|
||||
app: &mut App,
|
||||
tool_id: &str,
|
||||
tool_name: &str,
|
||||
input: &serde_json::Value,
|
||||
cell_index: usize,
|
||||
) {
|
||||
app.tool_cells.insert(tool_id.to_string(), cell_index);
|
||||
let record = ToolDetailRecord {
|
||||
tool_id: tool_id.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
input: input.clone(),
|
||||
output: None,
|
||||
};
|
||||
if cell_index < app.history.len() {
|
||||
app.tool_details_by_cell.insert(cell_index, record);
|
||||
} else {
|
||||
// Active-cell entry: keep the detail record in `active_tool_details`
|
||||
// until the active cell flushes. `flush_active_cell` migrates these
|
||||
// records into `tool_details_by_cell` keyed by the eventual real
|
||||
// cell index.
|
||||
app.active_tool_details.insert(tool_id.to_string(), record);
|
||||
}
|
||||
}
|
||||
|
||||
fn store_tool_detail_output(
|
||||
app: &mut App,
|
||||
tool_id: &str,
|
||||
cell_index: usize,
|
||||
result: &Result<ToolResult, ToolError>,
|
||||
) {
|
||||
let payload = Some(match result {
|
||||
Ok(tool_result) => tool_result.content.clone(),
|
||||
Err(err) => err.to_string(),
|
||||
});
|
||||
if cell_index < app.history.len()
|
||||
&& let Some(detail) = app.tool_details_by_cell.get_mut(&cell_index)
|
||||
{
|
||||
detail.output = payload.clone();
|
||||
}
|
||||
// Also write to the active table while the entry might still live there;
|
||||
// some callsites pre-rewrite cell_index but the active_tool_details map is
|
||||
// the canonical source for in-flight outputs.
|
||||
if let Some(detail) = app.active_tool_details.get_mut(tool_id) {
|
||||
detail.output = payload;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(super) fn handle_tool_call_complete(
|
||||
app: &mut App,
|
||||
id: &str,
|
||||
name: &str,
|
||||
result: &Result<ToolResult, ToolError>,
|
||||
) {
|
||||
if app.ignored_tool_calls.remove(id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Exploring entries land in the per-tool map regardless of whether they
|
||||
// live in the active cell or in finalized history; the path is the same.
|
||||
if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) {
|
||||
app.tool_cells.remove(id);
|
||||
store_tool_detail_output(app, id, cell_index, result);
|
||||
if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) =
|
||||
app.cell_at_virtual_index_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();
|
||||
// Mutating the in-flight exploring cell needs an active-cell
|
||||
// revision bump so the transcript cache invalidates the synthetic
|
||||
// tail row.
|
||||
if cell_index >= app.history.len() {
|
||||
app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
|
||||
if let Some(active) = app.active_cell.as_mut() {
|
||||
active.bump_revision();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the cell by tool id. If the id isn't registered, that's an
|
||||
// orphan completion (race condition where the started event was lost or
|
||||
// a tool result arrived after the active cell was already flushed). Build
|
||||
// a finalized standalone cell from the result so the user can still see
|
||||
// the output, but DO NOT touch the active cell.
|
||||
let Some(cell_index) = app.tool_cells.remove(id) else {
|
||||
push_orphan_tool_completion(app, id, name, result);
|
||||
return;
|
||||
};
|
||||
|
||||
store_tool_detail_output(app, id, cell_index, result);
|
||||
let in_active = cell_index >= app.history.len();
|
||||
|
||||
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.cell_at_virtual_index_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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// If the mutated cell lived inside the active group, bump the active-cell
|
||||
// revision so the transcript cache re-renders the synthetic tail row.
|
||||
if in_active {
|
||||
app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
|
||||
if let Some(active) = app.active_cell.as_mut() {
|
||||
active.bump_revision();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a finalized standalone history cell for a tool completion whose
|
||||
/// start was never registered (orphan). This preserves the contract that
|
||||
/// every tool result is visible somewhere; the alternative (silently
|
||||
/// dropping it) hides errors and breaks debuggability.
|
||||
///
|
||||
/// Choice of cell type: we use `GenericToolCell` because we have no input
|
||||
/// payload to reconstruct a more specific cell. The pager remains usable —
|
||||
/// `tool_details_by_cell` is populated with the result text.
|
||||
///
|
||||
/// ## Index drift
|
||||
///
|
||||
/// If an active cell is in flight when the orphan arrives, pushing the
|
||||
/// orphan into `app.history` shifts every active-cell virtual index forward
|
||||
/// by 1. We must rewrite `tool_cells` / `exploring_entries` accordingly so
|
||||
/// later completion lookups still find the right entries.
|
||||
fn push_orphan_tool_completion(
|
||||
app: &mut App,
|
||||
tool_id: &str,
|
||||
name: &str,
|
||||
result: &Result<ToolResult, ToolError>,
|
||||
) {
|
||||
let status = match result.as_ref() {
|
||||
Ok(tool_result) => {
|
||||
if tool_result.success {
|
||||
ToolStatus::Success
|
||||
} else {
|
||||
ToolStatus::Failed
|
||||
}
|
||||
}
|
||||
Err(_) => ToolStatus::Failed,
|
||||
};
|
||||
let output = match result.as_ref() {
|
||||
Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)),
|
||||
Err(err) => Some(err.to_string()),
|
||||
};
|
||||
let history_threshold_before_push = app.history.len();
|
||||
let active_in_flight = app.active_cell.is_some();
|
||||
app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: name.to_string(),
|
||||
status,
|
||||
input_summary: None,
|
||||
output,
|
||||
prompts: None,
|
||||
})));
|
||||
let cell_index = app.history.len().saturating_sub(1);
|
||||
app.tool_details_by_cell.insert(
|
||||
cell_index,
|
||||
ToolDetailRecord {
|
||||
tool_id: tool_id.to_string(),
|
||||
tool_name: name.to_string(),
|
||||
input: serde_json::Value::Null,
|
||||
output: match result.as_ref() {
|
||||
Ok(tool_result) => Some(tool_result.content.clone()),
|
||||
Err(err) => Some(err.to_string()),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Shift active-cell virtual indices forward by 1 to absorb the new
|
||||
// history cell. Without this, the next completion would address the
|
||||
// wrong entry.
|
||||
if active_in_flight {
|
||||
let threshold = history_threshold_before_push;
|
||||
for idx in app.tool_cells.values_mut() {
|
||||
if *idx >= threshold {
|
||||
*idx = idx.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
for (cell_idx, _) in app.exploring_entries.values_mut() {
|
||||
if *cell_idx >= threshold {
|
||||
*cell_idx = cell_idx.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
if let Some(idx) = app.exploring_cell.as_mut()
|
||||
&& *idx >= threshold
|
||||
{
|
||||
*idx = idx.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) 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!("Reading {path}")),
|
||||
"list_dir" => obj
|
||||
.and_then(|o| o.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map_or("Listing directory".to_string(), |path| {
|
||||
format!("Listing {path}")
|
||||
}),
|
||||
"grep_files" => {
|
||||
let pattern = obj
|
||||
.and_then(|o| o.get("pattern"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("pattern");
|
||||
format!("Searching for `{pattern}`")
|
||||
}
|
||||
"list_files" => "Listing 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())
|
||||
&& let Some(first) = searches.first()
|
||||
&& 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
|
||||
.first()
|
||||
.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.first()) {
|
||||
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
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
+12
-1227
File diff suppressed because it is too large
Load Diff
@@ -434,6 +434,30 @@ fn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {
|
||||
assert!(app.agent_activity_started_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_token_usage_updates_live_cost_counter_without_card_change() {
|
||||
let mut app = create_test_app();
|
||||
handle_subagent_mailbox(
|
||||
&mut app,
|
||||
1,
|
||||
&crate::tools::subagent::MailboxMessage::TokenUsage {
|
||||
agent_id: "agent-a".to_string(),
|
||||
model: "deepseek-v4-flash".to_string(),
|
||||
usage: crate::models::Usage {
|
||||
input_tokens: 10_000,
|
||||
output_tokens: 1_000,
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert!(app.subagent_cost > 0.0);
|
||||
assert!(
|
||||
app.history.is_empty(),
|
||||
"usage-only mailbox messages should not allocate a sub-agent card"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_token_count_compact_formats_units() {
|
||||
assert_eq!(format_token_count_compact(999), "999");
|
||||
|
||||
+25
-11
@@ -23,14 +23,14 @@ DeepSeek auth and model defaults. `deepseek login --api-key ...` writes the
|
||||
root `api_key` field that `deepseek-tui` reads directly, and `deepseek --model
|
||||
deepseek-v4-flash` is forwarded to the TUI as `DEEPSEEK_MODEL`.
|
||||
|
||||
For NVIDIA NIM-hosted DeepSeek V4 Pro, set `provider = "nvidia-nim"` or pass
|
||||
`deepseek --provider nvidia-nim`. The facade stores NIM credentials under
|
||||
`[providers.nvidia_nim]` and forwards the resolved key, base URL, provider, and
|
||||
model to the TUI process. Use
|
||||
`deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` to
|
||||
save the NIM key through the facade. `DEEPSEEK_API_KEY` remains a compatibility
|
||||
fallback when `DEEPSEEK_PROVIDER=nvidia-nim`, but `NVIDIA_API_KEY` or
|
||||
`NVIDIA_NIM_API_KEY` is preferred.
|
||||
For hosted or self-hosted DeepSeek V4 providers, set `provider = "nvidia-nim"`,
|
||||
`"fireworks"`, or `"sglang"` or pass `deepseek --provider <name>`. The facade
|
||||
stores provider credentials under `[providers.<name>]` and forwards the resolved
|
||||
key, base URL, provider, and model to the TUI process. Use
|
||||
`deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or
|
||||
`deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` to
|
||||
save hosted-provider keys through the facade. SGLang is self-hosted and can run
|
||||
without an API key by default.
|
||||
|
||||
To bootstrap MCP and skills directories at their resolved paths, run `deepseek-tui setup`.
|
||||
To only scaffold MCP, run `deepseek-tui mcp init`.
|
||||
@@ -58,6 +58,15 @@ provider = "nvidia-nim"
|
||||
api_key = "NVIDIA_KEY"
|
||||
base_url = "https://integrate.api.nvidia.com/v1"
|
||||
default_text_model = "deepseek-ai/deepseek-v4-pro"
|
||||
|
||||
[profiles.fireworks]
|
||||
provider = "fireworks"
|
||||
default_text_model = "accounts/fireworks/models/deepseek-v4-pro"
|
||||
|
||||
[profiles.sglang]
|
||||
provider = "sglang"
|
||||
base_url = "http://localhost:30000/v1"
|
||||
default_text_model = "deepseek-ai/DeepSeek-V4-Pro"
|
||||
```
|
||||
|
||||
Select a profile with:
|
||||
@@ -73,11 +82,16 @@ These override config values:
|
||||
|
||||
- `DEEPSEEK_API_KEY`
|
||||
- `DEEPSEEK_BASE_URL`
|
||||
- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim`)
|
||||
- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim|openrouter|novita|fireworks|sglang`)
|
||||
- `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL`
|
||||
- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (preferred when provider is `nvidia-nim`; falls back to `DEEPSEEK_API_KEY`)
|
||||
- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`
|
||||
- `NVIDIA_NIM_MODEL`
|
||||
- `FIREWORKS_API_KEY`
|
||||
- `FIREWORKS_BASE_URL`
|
||||
- `SGLANG_BASE_URL`
|
||||
- `SGLANG_MODEL`
|
||||
- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)
|
||||
- `DEEPSEEK_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)
|
||||
- `DEEPSEEK_SKILLS_DIR`
|
||||
- `DEEPSEEK_MCP_CONFIG`
|
||||
@@ -155,10 +169,10 @@ If you are upgrading from older releases:
|
||||
|
||||
### Core keys (used by the TUI/engine)
|
||||
|
||||
- `provider` (string, optional): `deepseek` (default) or `nvidia-nim`. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`.
|
||||
- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openrouter`, `novita`, `fireworks`, or `sglang`. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`.
|
||||
- `api_key` (string, required): must be non-empty (or set `DEEPSEEK_API_KEY`).
|
||||
- `base_url` (string, optional): defaults to `https://api.deepseek.com` for DeepSeek's OpenAI-compatible Chat Completions API, or `https://integrate.api.nvidia.com/v1` for `provider = "nvidia-nim"`. `https://api.deepseek.com/v1` is also accepted for SDK compatibility; use `https://api.deepseek.com/beta` only for DeepSeek beta features such as strict tool mode, chat prefix completion, and FIM completion.
|
||||
- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek or `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash`. With `provider = "nvidia-nim"`, `deepseek-v4-pro` maps to `deepseek-ai/deepseek-v4-pro` and `deepseek-v4-flash` maps to `deepseek-ai/deepseek-v4-flash`. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process.
|
||||
- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, and `deepseek-ai/DeepSeek-V4-Pro` for SGLang. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash`. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process.
|
||||
- `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.
|
||||
- `allow_shell` (bool, optional): defaults to `true` (sandboxed).
|
||||
- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.
|
||||
|
||||
Reference in New Issue
Block a user