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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions crates/tui/src/commands/balance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//! Balance: query the active provider's account balance or credit status.
//!
//! Provider-specific network dispatch is still pending. Until that lands, keep
//! this command explicit about being a scaffold so users do not mistake it for
//! a live balance lookup.

use crate::config::ApiProvider;
use crate::tui::app::App;

use super::CommandResult;

/// Query provider account balance / credits.
pub fn balance(app: &mut App) -> CommandResult {
let provider = app.api_provider;
match provider {
ApiProvider::Deepseek
| ApiProvider::DeepseekCN
| ApiProvider::Openrouter
| ApiProvider::Novita => CommandResult::message(format!(
"Balance check for {} is planned, but provider balance network dispatch is not wired in this build yet.",
provider.display_name()
)),
_ => CommandResult::message(format!(
"Balance check is not supported for {} yet. Check the provider dashboard for account balance details.",
provider.display_name()
)),
}
}
53 changes: 52 additions & 1 deletion crates/tui/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

mod anchor;
mod attachment;
mod balance;
mod change;
mod config;
mod core;
Expand Down Expand Up @@ -518,6 +519,13 @@ pub const COMMANDS: &[CommandInfo] = &[
usage: "/cost",
description_id: MessageId::CmdCostDescription,
},
// Balance query (#2019)
CommandInfo {
name: "balance",
aliases: &[],
usage: "/balance",
description_id: MessageId::CmdBalanceDescription,
},
// Profile switching (#390)
CommandInfo {
name: "profile",
Expand Down Expand Up @@ -603,6 +611,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"translate" | "translation" | "transale" => core::translate(app),
"tokens" => debug::tokens(app),
"cost" => debug::cost(app),
"balance" => balance::balance(app),
"cache" => debug::cache(app, arg),

// ChangeLog command
Expand Down Expand Up @@ -1063,7 +1072,7 @@ fn suggest_command_names(input: &str, limit: usize) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::config::{ApiProvider, Config};
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
use crate::tools::todo::TodoStatus;
use crate::tui::app::{App, AppAction, TuiOptions};
Expand Down Expand Up @@ -1485,6 +1494,48 @@ mod tests {
}
}

#[test]
fn balance_command_has_own_help_text() {
let info = get_command_info("balance").expect("balance command should be registered");
assert_eq!(info.description_id, MessageId::CmdBalanceDescription);
assert!(
info.description_for(Locale::En)
.contains("provider account balance")
);
}

#[test]
fn balance_command_reports_scaffold_without_claiming_dispatch() {
let mut app = create_test_app();
app.api_provider = ApiProvider::Deepseek;

let result = execute("/balance", &mut app);
let msg = result
.message
.expect("balance scaffold should explain current state");

assert!(!result.is_error);
assert!(msg.contains("DeepSeek"));
assert!(msg.contains("not wired"));
assert!(!msg.contains("sent"));
}

#[test]
fn balance_command_reports_unsupported_provider_clearly() {
let mut app = create_test_app();
app.api_provider = ApiProvider::Ollama;

let result = execute("/balance", &mut app);
let msg = result
.message
.expect("unsupported providers should return a clear message");

assert!(!result.is_error);
assert!(msg.contains("Ollama"));
assert!(msg.contains("not supported"));
assert!(msg.contains("dashboard"));
}

#[test]
fn unknown_command_suggests_nearest_match() {
let mut app = create_test_app();
Expand Down
7 changes: 7 additions & 0 deletions crates/tui/src/localization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ pub enum MessageId {
CmdChangeTranslationQueued,
CmdChangeTranslationUnavailable,
CmdChangePreviousVersion,
CmdBalanceDescription,
CmdClearDescription,
CmdCompactDescription,
CmdConfigDescription,
Expand Down Expand Up @@ -486,6 +487,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::HelpFooterClose,
MessageId::CmdAnchorDescription,
MessageId::CmdAttachDescription,
MessageId::CmdBalanceDescription,
MessageId::CmdCacheDescription,
MessageId::CmdClearDescription,
MessageId::CmdCompactDescription,
Expand Down Expand Up @@ -915,6 +917,7 @@ fn english(id: MessageId) -> &'static str {
MessageId::CmdChangePreviousVersion => {
"Previous version: {version} — run `/change {version}` to view it"
}
MessageId::CmdBalanceDescription => "Check the active provider account balance",
MessageId::CmdClearDescription => "Clear conversation history",
MessageId::CmdCompactDescription => {
"Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)"
Expand Down Expand Up @@ -1294,6 +1297,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::CmdChangePreviousVersion => {
"前のバージョン: {version} — `/change {version}` で表示"
}
MessageId::CmdBalanceDescription => "アクティブなプロバイダーのアカウント残高を確認",
MessageId::CmdClearDescription => "会話履歴をクリア",
MessageId::CmdCompactDescription => {
"コンテキスト圧縮で容量を確保(旧式:v0.6.6 以降はサイクル再起動を推奨)"
Expand Down Expand Up @@ -1648,6 +1652,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::CmdChangePreviousVersion => {
"上一个版本: {version} —— 输入 `/change {version}` 查看"
}
MessageId::CmdBalanceDescription => "查看当前提供商账户余额",
MessageId::CmdClearDescription => "清除对话历史",
MessageId::CmdCompactDescription => {
"触发上下文压缩以释放空间(旧版命令;v0.6.6 起建议改用循环重启)"
Expand Down Expand Up @@ -1962,6 +1967,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::CmdChangePreviousVersion => {
"Versão anterior: {version} — execute `/change {version}` para visualizar"
}
MessageId::CmdBalanceDescription => "Verificar o saldo da conta do provedor ativo",
MessageId::CmdClearDescription => "Limpar o histórico da conversa",
MessageId::CmdCompactDescription => {
"Compactar o contexto para liberar espaço (legado; a v0.6.6 prefere o reinício de ciclo)"
Expand Down Expand Up @@ -2348,6 +2354,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
MessageId::CmdChangePreviousVersion => {
"Versión anterior: {version} — ejecuta `/change {version}` para verla"
}
MessageId::CmdBalanceDescription => "Consultar el saldo de la cuenta del proveedor activo",
MessageId::CmdClearDescription => "Limpiar el historial de la conversación",
MessageId::CmdCompactDescription => {
"Compactar el contexto para liberar espacio (heredado; v0.6.6 prefiere reinicio de ciclo)"
Expand Down
154 changes: 135 additions & 19 deletions crates/tui/src/tools/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ use super::spec::{
use async_trait::async_trait;
use serde_json::{Value, json};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use tokio_util::sync::CancellationToken;

// === ReadFileTool ===

Expand Down Expand Up @@ -761,6 +763,8 @@ fn punctuation_normalized_matches(contents: &str, search: &str) -> Vec<(usize, u
/// Tool for listing directory contents.
pub struct ListDirTool;

const LIST_DIR_TIMEOUT: Duration = Duration::from_secs(30);

#[async_trait]
impl ToolSpec for ListDirTool {
fn name(&self) -> &'static str {
Expand Down Expand Up @@ -796,27 +800,104 @@ impl ToolSpec for ListDirTool {
let path_str = optional_str(&input, "path").unwrap_or(".");
let dir_path = context.resolve_path(path_str)?;

let mut entries = Vec::new();
let entries =
list_dir_entries_async(dir_path, context.cancel_token.clone(), LIST_DIR_TIMEOUT)
.await?;

for entry in fs::read_dir(&dir_path).map_err(|e| {
ToolError::execution_failed(format!(
"Failed to read directory {}: {}",
dir_path.display(),
e
))
})? {
let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
let file_type = entry
.file_type()
.map_err(|e| ToolError::execution_failed(e.to_string()))?;

entries.push(json!({
"name": entry.file_name().to_string_lossy().to_string(),
"is_dir": file_type.is_dir(),
}));
ToolResult::json(&entries).map_err(|e| ToolError::execution_failed(e.to_string()))
}
}

async fn list_dir_entries_async(
dir_path: PathBuf,
cancel_token: Option<CancellationToken>,
timeout: Duration,
) -> Result<Vec<Value>, ToolError> {
let worker_cancel_token = cancel_token.clone();
run_blocking_list_dir(timeout, cancel_token, move || {
list_dir_entries(&dir_path, worker_cancel_token.as_ref())
})
.await
}

async fn run_blocking_list_dir<F>(
timeout: Duration,
cancel_token: Option<CancellationToken>,
list_dir: F,
) -> Result<Vec<Value>, ToolError>
where
F: FnOnce() -> Result<Vec<Value>, ToolError> + Send + 'static,
{
if cancel_token
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
{
return Err(list_dir_cancelled());
}

let task = tokio::task::spawn_blocking(list_dir);
let result = match cancel_token {
Some(token) => {
tokio::select! {
biased;
() = token.cancelled() => return Err(list_dir_cancelled()),
result = tokio::time::timeout(timeout, task) => result,
}
}
None => tokio::time::timeout(timeout, task).await,
Comment on lines +838 to +847
};

ToolResult::json(&entries).map_err(|e| ToolError::execution_failed(e.to_string()))
let joined = result.map_err(|_| list_dir_timeout(timeout))?;
joined.map_err(|err| {
ToolError::execution_failed(format!("list_dir worker failed before completion: {err}"))
})?
}

fn list_dir_entries(
dir_path: &Path,
cancel_token: Option<&CancellationToken>,
) -> Result<Vec<Value>, ToolError> {
check_list_dir_cancelled(cancel_token)?;

let mut entries = Vec::new();

for entry in fs::read_dir(dir_path).map_err(|e| {
ToolError::execution_failed(format!(
"Failed to read directory {}: {}",
dir_path.display(),
e
))
})? {
check_list_dir_cancelled(cancel_token)?;

let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
let file_type = entry
.file_type()
.map_err(|e| ToolError::execution_failed(e.to_string()))?;

entries.push(json!({
"name": entry.file_name().to_string_lossy().to_string(),
"is_dir": file_type.is_dir(),
}));
}

Ok(entries)
}

fn check_list_dir_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
if cancel_token.is_some_and(CancellationToken::is_cancelled) {
return Err(list_dir_cancelled());
}
Ok(())
}

fn list_dir_cancelled() -> ToolError {
ToolError::execution_failed("list_dir cancelled before completion")
}

fn list_dir_timeout(timeout: Duration) -> ToolError {
ToolError::Timeout {
seconds: timeout.as_secs().max(1),
}
}

Expand Down Expand Up @@ -1647,6 +1728,41 @@ mod tests {
assert!(result.content.contains("nested.txt"));
}

#[tokio::test]
async fn test_list_dir_respects_cancel_token() {
let tmp = tempdir().expect("tempdir");
fs::write(tmp.path().join("file.txt"), "").expect("write");
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token);

let tool = ListDirTool;
let err = tool
.execute(json!({}), &ctx)
.await
.expect_err("cancelled list_dir should return an error");

assert!(
format!("{err:?}").contains("cancelled"),
"unexpected error: {err:?}"
);
}

#[tokio::test]
async fn test_list_dir_blocking_wrapper_reports_timeout() {
let err = run_blocking_list_dir(Duration::from_millis(1), None, || {
std::thread::sleep(Duration::from_millis(50));
Comment on lines +1753 to +1754
Ok(Vec::new())
})
.await
.expect_err("slow list_dir worker should time out");

assert!(
matches!(err, ToolError::Timeout { seconds: 1 }),
"unexpected error: {err:?}"
);
}

#[test]
fn test_read_file_tool_properties() {
let tool = ReadFileTool;
Expand Down
Loading
Loading