5f223adea6
Adds a structured rlm_query tool for parallel/batched LLM fan-out. The model calls it with one prompt or up to 16 concurrent prompts; children dispatch via tokio::join_all against the existing DeepSeek client. Default child model is deepseek-v4-flash; override per-call via the model field. Available in Plan / Agent / YOLO. Cost folds into the session's running total automatically. Fixes scroll-stuck regression (#56): TranscriptScroll::resolve_top and scrolled_by now use a three-level fallback chain (same line → same cell line 0 → nearest cell at-or-before) instead of teleporting to ToBottom when an anchor cell vanishes. Loosens command-safety chains (#57): cargo build && cargo test and similar chains of known-safe commands now escalate to RequiresApproval instead of being hard-blocked as Dangerous. Chains containing unknown commands still block. Suppresses the GettingCrowded footer chip — context-percent header already covers conversation pressure. Refactors: - Extracts file_mention parsing/completion/expansion (~450 LOC) from the 5,500-line ui.rs into crates/tui/src/tui/file_mention.rs. - Deletes truly unused helpers (write_bytes, timestamped_filename, extension_from_url, output_path, has_project_doc, primary_doc_path). Tests: 853 pass. cargo clippy --workspace -D warnings clean. cargo fmt --all -- --check clean. Closes #46 #47 #48 #49 #50 #53 #54 #55 #56 #57 #58.
208 lines
5.7 KiB
Rust
208 lines
5.7 KiB
Rust
//! Utility helpers shared across the `DeepSeek` CLI.
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use crate::models::{ContentBlock, Message};
|
|
use anyhow::{Context, Result};
|
|
use ignore::WalkBuilder;
|
|
use serde_json::Value;
|
|
|
|
// === Project Mapping Helpers ===
|
|
|
|
/// Identify if a file is a "key" file for project identification.
|
|
#[must_use]
|
|
pub fn is_key_file(path: &Path) -> bool {
|
|
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
return false;
|
|
};
|
|
|
|
matches!(
|
|
file_name.to_lowercase().as_str(),
|
|
"cargo.toml"
|
|
| "package.json"
|
|
| "requirements.txt"
|
|
| "build.gradle"
|
|
| "pom.xml"
|
|
| "readme.md"
|
|
| "agents.md"
|
|
| "claude.md"
|
|
| "makefile"
|
|
| "dockerfile"
|
|
| "main.rs"
|
|
| "lib.rs"
|
|
| "index.js"
|
|
| "index.ts"
|
|
| "app.py"
|
|
)
|
|
}
|
|
|
|
/// Generate a high-level summary of the project based on key files.
|
|
#[must_use]
|
|
pub fn summarize_project(root: &Path) -> String {
|
|
let mut key_files = Vec::new();
|
|
|
|
let mut builder = WalkBuilder::new(root);
|
|
builder.hidden(false).follow_links(true).max_depth(Some(2));
|
|
let walker = builder.build();
|
|
|
|
for entry in walker {
|
|
let entry = match entry {
|
|
Ok(entry) => entry,
|
|
Err(_) => continue,
|
|
};
|
|
if is_key_file(entry.path())
|
|
&& let Ok(rel) = entry.path().strip_prefix(root)
|
|
{
|
|
key_files.push(rel.to_string_lossy().to_string());
|
|
}
|
|
}
|
|
|
|
if key_files.is_empty() {
|
|
return "Unknown project type".to_string();
|
|
}
|
|
|
|
let mut types = Vec::new();
|
|
if key_files
|
|
.iter()
|
|
.any(|f| f.to_lowercase().contains("cargo.toml"))
|
|
{
|
|
types.push("Rust");
|
|
}
|
|
if key_files
|
|
.iter()
|
|
.any(|f| f.to_lowercase().contains("package.json"))
|
|
{
|
|
types.push("JavaScript/Node.js");
|
|
}
|
|
if key_files
|
|
.iter()
|
|
.any(|f| f.to_lowercase().contains("requirements.txt"))
|
|
{
|
|
types.push("Python");
|
|
}
|
|
|
|
if types.is_empty() {
|
|
format!("Project with key files: {}", key_files.join(", "))
|
|
} else {
|
|
format!("A {} project", types.join(" and "))
|
|
}
|
|
}
|
|
|
|
/// Generate a tree-like view of the project structure.
|
|
#[must_use]
|
|
pub fn project_tree(root: &Path, max_depth: usize) -> String {
|
|
let mut tree_lines = Vec::new();
|
|
|
|
let mut builder = WalkBuilder::new(root);
|
|
builder
|
|
.hidden(false)
|
|
.follow_links(true)
|
|
.max_depth(Some(max_depth + 1));
|
|
let walker = builder.build();
|
|
|
|
for entry in walker {
|
|
let entry = match entry {
|
|
Ok(entry) => entry,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
let path = entry.path();
|
|
let depth = entry.depth();
|
|
|
|
if depth == 0 || depth > max_depth {
|
|
continue;
|
|
}
|
|
|
|
let rel_path = path.strip_prefix(root).unwrap_or(path);
|
|
let indent = " ".repeat(depth - 1);
|
|
let prefix = if entry.file_type().is_some_and(|ft| ft.is_dir()) {
|
|
"DIR: "
|
|
} else {
|
|
"FILE: "
|
|
};
|
|
|
|
tree_lines.push(format!(
|
|
"{}{}{}",
|
|
indent,
|
|
prefix,
|
|
rel_path.file_name().unwrap_or_default().to_string_lossy()
|
|
));
|
|
}
|
|
|
|
tree_lines.join("\n")
|
|
}
|
|
|
|
// === Filesystem Helpers ===
|
|
|
|
#[allow(dead_code)]
|
|
pub fn ensure_dir(path: &Path) -> Result<()> {
|
|
fs::create_dir_all(path)
|
|
.with_context(|| format!("Failed to create directory: {}", path.display()))
|
|
}
|
|
|
|
/// Render JSON with pretty formatting, falling back to a compact string on error.
|
|
#[must_use]
|
|
#[allow(dead_code)]
|
|
pub fn pretty_json(value: &Value) -> String {
|
|
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
|
|
}
|
|
|
|
/// Truncate a string to a maximum length, adding an ellipsis if truncated.
|
|
///
|
|
/// Uses char boundaries to avoid panicking on multi-byte UTF-8 characters.
|
|
#[must_use]
|
|
pub fn truncate_with_ellipsis(s: &str, max_len: usize, ellipsis: &str) -> String {
|
|
if s.len() <= max_len {
|
|
return s.to_string();
|
|
}
|
|
let budget = max_len.saturating_sub(ellipsis.len());
|
|
// Find the last char boundary that fits within the byte budget.
|
|
let safe_end = s
|
|
.char_indices()
|
|
.map(|(i, _)| i)
|
|
.take_while(|&i| i <= budget)
|
|
.last()
|
|
.unwrap_or(0);
|
|
format!("{}{}", &s[..safe_end], ellipsis)
|
|
}
|
|
|
|
/// Percent-encode a string for use in URL query parameters.
|
|
///
|
|
/// Encodes all characters except unreserved characters (A-Z, a-z, 0-9, `-`, `_`, `.`, `~`).
|
|
/// Spaces are encoded as `+`.
|
|
#[must_use]
|
|
pub fn url_encode(input: &str) -> String {
|
|
let mut encoded = String::new();
|
|
for ch in input.bytes() {
|
|
match ch {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
encoded.push(ch as char)
|
|
}
|
|
b' ' => encoded.push('+'),
|
|
_ => encoded.push_str(&format!("%{ch:02X}")),
|
|
}
|
|
}
|
|
encoded
|
|
}
|
|
|
|
/// Estimate the total character count across message content blocks.
|
|
#[must_use]
|
|
pub fn estimate_message_chars(messages: &[Message]) -> usize {
|
|
let mut total = 0;
|
|
for msg in messages {
|
|
for block in &msg.content {
|
|
match block {
|
|
ContentBlock::Text { text, .. } => total += text.len(),
|
|
ContentBlock::Thinking { thinking } => total += thinking.len(),
|
|
ContentBlock::ToolUse { input, .. } => total += input.to_string().len(),
|
|
ContentBlock::ToolResult { content, .. } => total += content.len(),
|
|
ContentBlock::ServerToolUse { .. }
|
|
| ContentBlock::ToolSearchToolResult { .. }
|
|
| ContentBlock::CodeExecutionToolResult { .. } => {}
|
|
}
|
|
}
|
|
}
|
|
total
|
|
}
|