Rank file picker by working set relevance (#155)
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
//! Enter emits a [`ViewEvent::FilePickerSelected`] which the UI handler turns
|
||||
//! into an `@<path>` insertion at the composer cursor.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
@@ -36,9 +37,79 @@ const WALK_DEPTH: usize = 6;
|
||||
/// Visible candidate rows in the overlay.
|
||||
const VISIBLE_ROWS: usize = 14;
|
||||
|
||||
const MODIFIED_BOOST: i32 = 360;
|
||||
const MENTIONED_BOOST: i32 = 240;
|
||||
const TOOL_BOOST: i32 = 160;
|
||||
|
||||
/// Working-set hints captured when the picker opens.
|
||||
///
|
||||
/// The picker keeps this as plain path strings so filtering stays in-memory and
|
||||
/// per-keystroke work remains the same shape as the original fuzzy search.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct FilePickerRelevance {
|
||||
modified: HashSet<String>,
|
||||
mentioned: HashSet<String>,
|
||||
tool: HashSet<String>,
|
||||
}
|
||||
|
||||
impl FilePickerRelevance {
|
||||
pub fn mark_modified(&mut self, path: impl Into<String>) {
|
||||
let path = path.into();
|
||||
if !path.is_empty() {
|
||||
self.modified.insert(path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_mentioned(&mut self, path: impl Into<String>) {
|
||||
let path = path.into();
|
||||
if !path.is_empty() {
|
||||
self.mentioned.insert(path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_tool(&mut self, path: impl Into<String>) {
|
||||
let path = path.into();
|
||||
if !path.is_empty() {
|
||||
self.tool.insert(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn boost_for(&self, path: &str) -> i32 {
|
||||
let mut boost = 0;
|
||||
if self.modified.contains(path) {
|
||||
boost += MODIFIED_BOOST;
|
||||
}
|
||||
if self.mentioned.contains(path) {
|
||||
boost += MENTIONED_BOOST;
|
||||
}
|
||||
if self.tool.contains(path) {
|
||||
boost += TOOL_BOOST;
|
||||
}
|
||||
boost
|
||||
}
|
||||
|
||||
fn markers_for(&self, path: &str) -> String {
|
||||
let mut markers = String::with_capacity(3);
|
||||
markers.push(if self.modified.contains(path) {
|
||||
'M'
|
||||
} else {
|
||||
' '
|
||||
});
|
||||
markers.push(if self.mentioned.contains(path) {
|
||||
'@'
|
||||
} else {
|
||||
' '
|
||||
});
|
||||
markers.push(if self.tool.contains(path) { 'T' } else { ' ' });
|
||||
markers
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FilePickerView {
|
||||
/// All workspace-relative candidate paths, captured once at construction.
|
||||
candidates: Vec<String>,
|
||||
/// Working-set relevance hints, captured once at construction.
|
||||
relevance: FilePickerRelevance,
|
||||
/// Filtered indices into `candidates`, sorted by descending score.
|
||||
filtered: Vec<usize>,
|
||||
/// User's typed query (lowercased on each refilter).
|
||||
@@ -50,12 +121,12 @@ pub struct FilePickerView {
|
||||
}
|
||||
|
||||
impl FilePickerView {
|
||||
/// Build a picker rooted at `workspace_root`. Performs the directory walk
|
||||
/// eagerly so per-keystroke filtering stays in memory.
|
||||
pub fn new(workspace_root: &Path) -> Self {
|
||||
/// Build a picker with working-set relevance hints.
|
||||
pub fn new_with_relevance(workspace_root: &Path, relevance: FilePickerRelevance) -> Self {
|
||||
let candidates = collect_candidates(workspace_root);
|
||||
let mut view = Self {
|
||||
candidates,
|
||||
relevance,
|
||||
filtered: Vec::new(),
|
||||
query: String::new(),
|
||||
selected: 0,
|
||||
@@ -67,17 +138,25 @@ impl FilePickerView {
|
||||
|
||||
fn refilter(&mut self) {
|
||||
let query = self.query.trim().to_lowercase();
|
||||
let mut scored: Vec<(usize, i32)> = if query.is_empty() {
|
||||
let mut scored: Vec<(usize, i32, i32, i32)> = if query.is_empty() {
|
||||
self.candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, _)| (idx, 0))
|
||||
.map(|(idx, path)| {
|
||||
let boost = self.relevance.boost_for(path);
|
||||
(idx, boost, 0, boost)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
self.candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, path)| score(&query, path).map(|s| (idx, s)))
|
||||
.filter_map(|(idx, path)| {
|
||||
score(&query, path).map(|fuzzy| {
|
||||
let boost = self.relevance.boost_for(path);
|
||||
(idx, fuzzy + boost, fuzzy, boost)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -85,11 +164,13 @@ impl FilePickerView {
|
||||
// so shorter / more central matches surface above deep nested ones.
|
||||
scored.sort_by(|a, b| {
|
||||
b.1.cmp(&a.1)
|
||||
.then_with(|| b.2.cmp(&a.2))
|
||||
.then_with(|| b.3.cmp(&a.3))
|
||||
.then_with(|| self.candidates[a.0].len().cmp(&self.candidates[b.0].len()))
|
||||
.then_with(|| self.candidates[a.0].cmp(&self.candidates[b.0]))
|
||||
});
|
||||
|
||||
self.filtered = scored.into_iter().map(|(idx, _)| idx).collect();
|
||||
self.filtered = scored.into_iter().map(|(idx, _, _, _)| idx).collect();
|
||||
if self.filtered.is_empty() {
|
||||
self.selected = 0;
|
||||
self.scroll = 0;
|
||||
@@ -145,6 +226,11 @@ impl FilePickerView {
|
||||
pub fn selected_for_test(&self) -> Option<&str> {
|
||||
self.selected_path()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn markers_for_test(&self, path: &str) -> String {
|
||||
self.relevance.markers_for(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for FilePickerView {
|
||||
@@ -282,8 +368,14 @@ impl ModalView for FilePickerView {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
let prefix = if selected { "▶ " } else { " " };
|
||||
let display = truncate_path(path, inner.width.saturating_sub(2) as usize);
|
||||
let mut line = Line::from(format!("{prefix}{display}"));
|
||||
let marker_field = if inner.width >= 18 {
|
||||
format!("{} ", self.relevance.markers_for(path))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let reserved = prefix.chars().count() + marker_field.chars().count();
|
||||
let display = truncate_path(path, (inner.width as usize).saturating_sub(reserved));
|
||||
let mut line = Line::from(format!("{prefix}{marker_field}{display}"));
|
||||
line.style = style;
|
||||
lines.push(line);
|
||||
}
|
||||
@@ -490,7 +582,7 @@ mod tests {
|
||||
fs::write(root.join("README.md"), "").unwrap();
|
||||
fs::write(root.join("Cargo.toml"), "").unwrap();
|
||||
|
||||
let mut view = FilePickerView::new(root);
|
||||
let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
|
||||
// Empty query -> all 4 files visible.
|
||||
assert_eq!(view.visible_count(), 4, "expected all 4 candidates");
|
||||
|
||||
@@ -505,6 +597,43 @@ mod tests {
|
||||
assert!(selected.ends_with("main.rs"), "selected = {selected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_empty_query_prioritizes_working_set_files() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("src")).unwrap();
|
||||
fs::write(root.join("src/main.rs"), "").unwrap();
|
||||
fs::write(root.join("src/lib.rs"), "").unwrap();
|
||||
fs::write(root.join("README.md"), "").unwrap();
|
||||
|
||||
let mut relevance = FilePickerRelevance::default();
|
||||
relevance.mark_modified("src/lib.rs");
|
||||
let view = FilePickerView::new_with_relevance(root, relevance);
|
||||
|
||||
assert_eq!(view.selected_for_test(), Some("src/lib.rs"));
|
||||
assert_eq!(view.markers_for_test("src/lib.rs"), "M ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_fuzzy_query_keeps_working_set_boosts() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("src")).unwrap();
|
||||
fs::write(root.join("src/alpha.rs"), "").unwrap();
|
||||
fs::write(root.join("src/zeta.rs"), "").unwrap();
|
||||
|
||||
let mut relevance = FilePickerRelevance::default();
|
||||
relevance.mark_mentioned("src/zeta.rs");
|
||||
relevance.mark_tool("src/zeta.rs");
|
||||
let mut view = FilePickerView::new_with_relevance(root, relevance);
|
||||
for ch in "rs".chars() {
|
||||
view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
assert_eq!(view.selected_for_test(), Some("src/zeta.rs"));
|
||||
assert_eq!(view.markers_for_test("src/zeta.rs"), " @T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_backspace_widens_candidates() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
@@ -512,7 +641,7 @@ mod tests {
|
||||
fs::write(root.join("a.txt"), "").unwrap();
|
||||
fs::write(root.join("b.txt"), "").unwrap();
|
||||
|
||||
let mut view = FilePickerView::new(root);
|
||||
let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
|
||||
view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
assert_eq!(view.visible_count(), 1);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
@@ -525,7 +654,7 @@ mod tests {
|
||||
let root = dir.path();
|
||||
fs::write(root.join("only.txt"), "").unwrap();
|
||||
|
||||
let mut view = FilePickerView::new(root);
|
||||
let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match action {
|
||||
ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path }) => {
|
||||
@@ -541,7 +670,7 @@ mod tests {
|
||||
let root = dir.path();
|
||||
fs::write(root.join("only.txt"), "").unwrap();
|
||||
|
||||
let mut view = FilePickerView::new(root);
|
||||
let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::Close));
|
||||
}
|
||||
@@ -557,7 +686,7 @@ mod tests {
|
||||
fs::write(root.join("keepme.txt"), "").unwrap();
|
||||
fs::write(root.join("skipme.txt"), "").unwrap();
|
||||
|
||||
let view = FilePickerView::new(root);
|
||||
let view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
|
||||
let visible: Vec<_> = view
|
||||
.filtered
|
||||
.iter()
|
||||
|
||||
+221
-2
@@ -1,5 +1,6 @@
|
||||
//! TUI event loop and rendering logic for `DeepSeek` CLI.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{self, Stdout};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@@ -1348,8 +1349,7 @@ async fn run_event_loop(
|
||||
&& app.view_stack.is_empty()
|
||||
&& !app.is_loading
|
||||
{
|
||||
app.view_stack
|
||||
.push(crate::tui::file_picker::FilePickerView::new(&app.workspace));
|
||||
open_file_picker(app);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2616,6 +2616,225 @@ fn open_context_inspector(app: &mut App) {
|
||||
));
|
||||
}
|
||||
|
||||
fn open_file_picker(app: &mut App) {
|
||||
let relevance = build_file_picker_relevance(app);
|
||||
app.view_stack
|
||||
.push(crate::tui::file_picker::FilePickerView::new_with_relevance(
|
||||
&app.workspace,
|
||||
relevance,
|
||||
));
|
||||
}
|
||||
|
||||
fn build_file_picker_relevance(app: &App) -> crate::tui::file_picker::FilePickerRelevance {
|
||||
let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();
|
||||
|
||||
for path in modified_workspace_paths(&app.workspace) {
|
||||
relevance.mark_modified(path);
|
||||
}
|
||||
|
||||
for record in app.session_context_references.iter().rev().take(64) {
|
||||
let reference = &record.reference;
|
||||
if reference.source != crate::tui::file_mention::ContextReferenceSource::AtMention {
|
||||
continue;
|
||||
}
|
||||
if !matches!(
|
||||
reference.kind,
|
||||
crate::tui::file_mention::ContextReferenceKind::File
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for raw in [&reference.target, &reference.label] {
|
||||
if let Some(path) = workspace_file_candidate(raw, &app.workspace) {
|
||||
relevance.mark_mentioned(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen_tool_paths = HashSet::new();
|
||||
for detail in app.active_tool_details.values() {
|
||||
mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance);
|
||||
}
|
||||
let mut rows: Vec<_> = app.tool_details_by_cell.iter().collect();
|
||||
rows.sort_by_key(|(idx, _)| std::cmp::Reverse(**idx));
|
||||
for (_, detail) in rows.into_iter().take(48) {
|
||||
mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance);
|
||||
}
|
||||
|
||||
relevance
|
||||
}
|
||||
|
||||
fn modified_workspace_paths(workspace: &Path) -> Vec<String> {
|
||||
let Ok(output) = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace)
|
||||
.args(["status", "--short", "--untracked-files=normal"])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(parse_git_status_path)
|
||||
.filter_map(|path| workspace_file_candidate(&path, workspace))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_git_status_path(line: &str) -> Option<String> {
|
||||
if line.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let raw = line.get(3..)?.trim();
|
||||
let raw = raw.rsplit(" -> ").next().unwrap_or(raw).trim();
|
||||
let raw = raw.trim_matches('"');
|
||||
if raw.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(raw.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_tool_detail_paths(
|
||||
detail: &ToolDetailRecord,
|
||||
workspace: &Path,
|
||||
seen: &mut HashSet<String>,
|
||||
relevance: &mut crate::tui::file_picker::FilePickerRelevance,
|
||||
) {
|
||||
let mut budget = 256usize;
|
||||
mark_tool_paths_from_value(&detail.input, workspace, seen, relevance, &mut budget);
|
||||
if let Some(output) = detail
|
||||
.output
|
||||
.as_deref()
|
||||
.filter(|output| output.len() <= 8_192)
|
||||
{
|
||||
mark_tool_paths_from_text(output, workspace, seen, relevance, &mut budget);
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_tool_paths_from_value(
|
||||
value: &serde_json::Value,
|
||||
workspace: &Path,
|
||||
seen: &mut HashSet<String>,
|
||||
relevance: &mut crate::tui::file_picker::FilePickerRelevance,
|
||||
budget: &mut usize,
|
||||
) {
|
||||
if *budget == 0 {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
serde_json::Value::String(text) => {
|
||||
mark_tool_paths_from_text(text, workspace, seen, relevance, budget);
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
mark_tool_paths_from_value(item, workspace, seen, relevance, budget);
|
||||
if *budget == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for item in map.values() {
|
||||
mark_tool_paths_from_value(item, workspace, seen, relevance, budget);
|
||||
if *budget == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_tool_paths_from_text(
|
||||
text: &str,
|
||||
workspace: &Path,
|
||||
seen: &mut HashSet<String>,
|
||||
relevance: &mut crate::tui::file_picker::FilePickerRelevance,
|
||||
budget: &mut usize,
|
||||
) {
|
||||
if *budget == 0 || text.len() > 8_192 {
|
||||
return;
|
||||
}
|
||||
if let Some(path) = workspace_file_candidate(text, workspace)
|
||||
&& seen.insert(path.clone())
|
||||
{
|
||||
relevance.mark_tool(path);
|
||||
*budget = (*budget).saturating_sub(1);
|
||||
}
|
||||
for token in text.split_whitespace().take(128) {
|
||||
if *budget == 0 {
|
||||
break;
|
||||
}
|
||||
if let Some(path) = workspace_file_candidate(token, workspace)
|
||||
&& seen.insert(path.clone())
|
||||
{
|
||||
relevance.mark_tool(path);
|
||||
*budget = (*budget).saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_file_candidate(raw: &str, workspace: &Path) -> Option<String> {
|
||||
let cleaned = clean_path_token(raw)?;
|
||||
let path = Path::new(&cleaned);
|
||||
let absolute = if path.is_absolute() {
|
||||
PathBuf::from(path)
|
||||
} else {
|
||||
workspace.join(path)
|
||||
};
|
||||
if !absolute.is_file() {
|
||||
return None;
|
||||
}
|
||||
let rel = absolute.strip_prefix(workspace).ok()?;
|
||||
workspace_path_to_picker_string(rel)
|
||||
}
|
||||
|
||||
fn clean_path_token(raw: &str) -> Option<String> {
|
||||
let mut trimmed = raw.trim().trim_matches(|ch: char| {
|
||||
ch.is_ascii_whitespace()
|
||||
|| matches!(
|
||||
ch,
|
||||
'"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';'
|
||||
)
|
||||
});
|
||||
if let Some(stripped) = trimmed.strip_prefix("./") {
|
||||
trimmed = stripped;
|
||||
}
|
||||
if let Some((before, after)) = trimmed.rsplit_once(':')
|
||||
&& !before.is_empty()
|
||||
&& after.chars().all(|ch| ch.is_ascii_digit())
|
||||
{
|
||||
trimmed = before;
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_path_to_picker_string(path: &Path) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
for (idx, component) in path.components().enumerate() {
|
||||
if matches!(
|
||||
component,
|
||||
std::path::Component::ParentDir
|
||||
| std::path::Component::RootDir
|
||||
| std::path::Component::Prefix(_)
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
if idx > 0 {
|
||||
out.push('/');
|
||||
}
|
||||
out.push_str(&component.as_os_str().to_string_lossy());
|
||||
}
|
||||
if out.is_empty() { None } else { Some(out) }
|
||||
}
|
||||
|
||||
async fn apply_command_result(
|
||||
app: &mut App,
|
||||
engine_handle: &mut EngineHandle,
|
||||
|
||||
@@ -114,6 +114,56 @@ fn transcript_scroll_percent_is_clamped_and_relative() {
|
||||
assert_eq!(transcript_scroll_percent(0, 20, 20), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_git_status_path_handles_simple_and_renamed_entries() {
|
||||
assert_eq!(
|
||||
parse_git_status_path(" M crates/tui/src/tui/ui.rs"),
|
||||
Some("crates/tui/src/tui/ui.rs".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_git_status_path("R old name.rs -> crates/tui/src/tui/file_picker.rs"),
|
||||
Some("crates/tui/src/tui/file_picker.rs".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
let path = root.join("src/lib.rs");
|
||||
std::fs::write(&path, "").unwrap();
|
||||
|
||||
let raw = format!("\"{}:42\",", path.display());
|
||||
assert_eq!(
|
||||
workspace_file_candidate(&raw, root),
|
||||
Some("src/lib.rs".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_path_relevance_extracts_paths_from_command_text() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/alpha.rs"), "").unwrap();
|
||||
std::fs::write(root.join("src/zeta.rs"), "").unwrap();
|
||||
|
||||
let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();
|
||||
let mut seen = HashSet::new();
|
||||
let mut budget = 16;
|
||||
mark_tool_paths_from_text(
|
||||
"sed -n '1,20p' src/zeta.rs",
|
||||
root,
|
||||
&mut seen,
|
||||
&mut relevance,
|
||||
&mut budget,
|
||||
);
|
||||
|
||||
let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);
|
||||
assert_eq!(view.selected_for_test(), Some("src/zeta.rs"));
|
||||
}
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user