Skip to content

Commit b0c75b6

Browse files
committed
feat: integrate LLM-based suggestions into TUI
- Added support for LLM-based suggestions using the Qwen2 model. - Updated `Cargo.toml` to include necessary dependencies for LLM functionality. - Modified `App` struct to include ensemble for multi-model suggestions. - Enhanced `run_tui` and `run_tui_loop` functions to handle LLM configuration. - Implemented LLM model loading and suggestion generation in a new `llm.rs` module. - Updated SQLite schema to support command execution history. - Added command hashing and persistence logic for improved history tracking.
1 parent fa7e1d6 commit b0c75b6

File tree

8 files changed

+1881
-51
lines changed

8 files changed

+1881
-51
lines changed

rust/Cargo.lock

Lines changed: 1260 additions & 22 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ libsql-client = "0.33.4"
88
libsql = "0.9.24"
99
tokio = { version = "1", features = ["full"] }
1010
anyhow = "1"
11+
llama-cpp-2 = "0.1.124"
1112

1213
clap = { version = "4", features = ["derive"] }
1314
fuzzy-matcher = "0.3"
@@ -22,3 +23,9 @@ log = "0.4"
2223
env_logger = "0.11"
2324
sha2 = "0.10"
2425
hex = "0.4"
26+
# LLM inference with candle - using git to avoid rand version conflicts
27+
candle-core = { git = "https://github.com/huggingface/candle.git", features = ["accelerate"] }
28+
candle-transformers = { git = "https://github.com/huggingface/candle.git" }
29+
candle-nn = { git = "https://github.com/huggingface/candle.git" }
30+
tokenizers = "0.20"
31+
hf-hub = "0.3"

rust/src/core.rs

Lines changed: 150 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@ use anyhow::{bail, Context, Result};
33
use directories::UserDirs;
44
use fuzzy_matcher::skim::SkimMatcherV2;
55
use fuzzy_matcher::FuzzyMatcher;
6+
use hex::encode;
67
use libsql::Value;
78
use once_cell::sync::Lazy;
89
use ratatui::layout::Rect;
10+
use sha2::{Digest, Sha256};
911
use std::cmp::Ordering;
1012
use std::fs::File;
1113
use std::io::Read;
1214
use std::path::{Path, PathBuf};
1315

1416
use crate::model::{
15-
AliasModel, EnsembleBuilder, FreqModel, PrefixModel, SqlitePool, SuggestModel, Suggestion,
17+
AliasModel, EnsembleBuilder, FreqModel, LlmConfig, LlmModel, LlmWhich, PrefixModel,
18+
SqlitePool, SuggestModel, Suggestion,
1619
};
20+
use crate::model::ensemble::Ensemble;
1721

1822
static MATCHER: Lazy<SkimMatcherV2> = Lazy::new(SkimMatcherV2::default);
1923
/// Run the fuzzy search over one or more history files
@@ -95,20 +99,54 @@ pub struct App {
9599
pub output_scroll: u16, // scroll offset for main tab output
96100
pub history_scroll: u16, // scroll offset for history tab output
97101

98-
// corpus
102+
// corpus (legacy fuzzy matching)
99103
pub corpus: Vec<String>,
104+
105+
// ensemble for multi-model suggestions
106+
pub ensemble: Ensemble,
100107
}
101108

102109
impl App {
103-
pub fn new(corpus: Vec<String>, top: usize, db: Option<SqlitePool>) -> Self {
110+
pub fn new(
111+
corpus: Vec<String>,
112+
top: usize,
113+
db: Option<SqlitePool>,
114+
enable_llm: bool,
115+
llm_model: Option<PathBuf>,
116+
llm_which: String,
117+
) -> Result<Self> {
104118
// Load recent history from database
105119
let history = if let Some(ref pool) = db {
106120
load_recent_history(pool, 100).unwrap_or_default()
107121
} else {
108122
Vec::new()
109123
};
110124

111-
Self {
125+
// Build ensemble with all suggestion models
126+
let mut builder = EnsembleBuilder::new().with_light_model(FuzzyHistoryModel::new(corpus.clone()));
127+
128+
// Add database-backed models if available
129+
if let Some(ref pool) = db {
130+
builder = builder
131+
.with_light_model(PrefixModel::new(pool.clone()))
132+
.with_light_model(FreqModel::new(pool.clone()))
133+
.with_light_model(AliasModel::with_sql_store(pool.clone()));
134+
}
135+
136+
// Add LLM model if enabled
137+
if enable_llm {
138+
let which = LlmWhich::from_str(&llm_which).unwrap_or(LlmWhich::W0_5b);
139+
let llm_config = LlmConfig {
140+
model_path: llm_model,
141+
which,
142+
..Default::default()
143+
};
144+
builder = builder.with_light_model(LlmModel::new(llm_config));
145+
}
146+
147+
let ensemble = builder.build();
148+
149+
Ok(Self {
112150
input: String::new(),
113151
cursor: 0,
114152
suggestions: Vec::new(),
@@ -127,7 +165,8 @@ impl App {
127165
output_scroll: 0,
128166
history_scroll: 0,
129167
corpus,
130-
}
168+
ensemble,
169+
})
131170
}
132171

133172
pub fn refresh_suggestions(&mut self) {
@@ -136,19 +175,38 @@ impl App {
136175
self.selected = 0;
137176
return;
138177
}
178+
139179
let query = self.input.as_str();
140-
let mut scored: Vec<(i64, String)> = Vec::new();
141-
for line in self.corpus.iter() {
142-
if let Some(score) = MATCHER.fuzzy_match(line, query) {
143-
scored.push((score, line.clone()));
180+
181+
// Use ensemble for multi-model suggestions
182+
match self.ensemble.predict(query) {
183+
Ok(suggestions) => {
184+
self.suggestions = suggestions
185+
.into_iter()
186+
.take(self.max_suggestions)
187+
.map(|s| s.text)
188+
.collect();
189+
}
190+
Err(e) => {
191+
// Fallback to simple fuzzy matching if ensemble fails
192+
use log::warn;
193+
warn!("Ensemble prediction failed: {}. Falling back to fuzzy matching.", e);
194+
195+
let mut scored: Vec<(i64, String)> = Vec::new();
196+
for line in self.corpus.iter() {
197+
if let Some(score) = MATCHER.fuzzy_match(line, query) {
198+
scored.push((score, line.clone()));
199+
}
200+
}
201+
scored.sort_by(|a, b| b.0.cmp(&a.0));
202+
self.suggestions = scored
203+
.into_iter()
204+
.take(self.max_suggestions)
205+
.map(|(_, s)| s)
206+
.collect();
144207
}
145208
}
146-
scored.sort_by(|a, b| b.0.cmp(&a.0));
147-
self.suggestions = scored
148-
.into_iter()
149-
.take(self.max_suggestions)
150-
.map(|(_, s)| s)
151-
.collect();
209+
152210
self.selected = self.selected.min(self.suggestions.len().saturating_sub(1));
153211
}
154212

@@ -197,7 +255,7 @@ pub fn read_history_file(path: &Path) -> Result<Vec<String>> {
197255

198256
fn load_recent_history(pool: &SqlitePool, limit: usize) -> Result<Vec<HistoryEntry>> {
199257
pool.query_collect(
200-
"SELECT command, output FROM history ORDER BY id DESC LIMIT ?1",
258+
"SELECT command, output FROM command_executions ORDER BY executed_at DESC LIMIT ?1",
201259
vec![Value::Integer(limit as i64)],
202260
|row| {
203261
let command: String = row.get(0)?;
@@ -216,6 +274,82 @@ fn load_recent_history(pool: &SqlitePool, limit: usize) -> Result<Vec<HistoryEnt
216274
)
217275
}
218276

277+
fn hash_command(command: &str) -> String {
278+
let mut hasher = Sha256::new();
279+
hasher.update(command.as_bytes());
280+
encode(hasher.finalize())
281+
}
282+
283+
pub fn persist_command_to_history(pool: &SqlitePool, command: &str, session_id: &str) -> Result<()> {
284+
let trimmed = command.trim();
285+
if trimmed.is_empty() {
286+
return Ok(());
287+
}
288+
289+
let hash = hash_command(trimmed);
290+
291+
// Update history table (for frequency counting)
292+
pool.execute(
293+
r#"
294+
INSERT INTO history (command, hash, count, source, output)
295+
VALUES (?1, ?2, 1, 'tui', '')
296+
ON CONFLICT(hash) DO UPDATE SET
297+
count = count + 1,
298+
source = 'tui',
299+
created_at = CURRENT_TIMESTAMP;
300+
"#,
301+
vec![
302+
Value::Text(trimmed.to_string()),
303+
Value::Text(hash),
304+
],
305+
)?;
306+
307+
// Insert into command_executions (for full history with output)
308+
pool.execute(
309+
r#"
310+
INSERT INTO command_executions (command, output, session_id, executed_at)
311+
VALUES (?1, '', ?2, CURRENT_TIMESTAMP);
312+
"#,
313+
vec![
314+
Value::Text(trimmed.to_string()),
315+
Value::Text(session_id.to_string()),
316+
],
317+
)?;
318+
319+
Ok(())
320+
}
321+
322+
pub fn import_shell_history_to_db(pool: &SqlitePool, files: &[PathBuf]) -> Result<()> {
323+
let lines = load_history_lines(files.to_vec(), true)?; // unique=true to avoid duplicates in memory
324+
325+
for command in lines {
326+
let trimmed = command.trim();
327+
if trimmed.is_empty() {
328+
continue;
329+
}
330+
331+
let hash = hash_command(trimmed);
332+
333+
// Insert into history table with source='shell'
334+
// On conflict, just increment count (don't change source from 'tui' to 'shell')
335+
pool.execute(
336+
r#"
337+
INSERT INTO history (command, hash, count, source, output)
338+
VALUES (?1, ?2, 1, 'shell', '')
339+
ON CONFLICT(hash) DO UPDATE SET
340+
count = count + 1,
341+
created_at = CURRENT_TIMESTAMP;
342+
"#,
343+
vec![
344+
Value::Text(trimmed.to_string()),
345+
Value::Text(hash),
346+
],
347+
).ok(); // Ignore errors for individual commands
348+
}
349+
350+
Ok(())
351+
}
352+
219353
#[derive(Debug)]
220354
struct FuzzyHistoryModel {
221355
corpus: Vec<String>,

rust/src/main.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ enum Cmd {
3232
/// Remove duplicate lines
3333
#[arg(long, default_value_t = true)]
3434
unique: bool,
35+
36+
/// Enable LLM-based suggestions
37+
#[arg(long, default_value_t = false)]
38+
enable_llm: bool,
39+
40+
/// Path to GGUF model file (optional, downloads from HF if not provided)
41+
#[arg(long)]
42+
llm_model: Option<PathBuf>,
43+
44+
/// LLM model variant (0.5b, 1.5b, 7b)
45+
#[arg(long, default_value = "0.5b")]
46+
llm_which: String,
3547
},
3648

3749
/// Non-TUI fuzzy search (existing behavior)
@@ -51,7 +63,14 @@ fn main() -> Result<()> {
5163
let _ = env_logger::Builder::from_env(Env::default().default_filter_or("info")).try_init();
5264
let cli = Cli::parse();
5365
match cli.cmd {
54-
Some(Cmd::Tui { files, top, unique }) => tui::run_tui_loop(files, top, unique),
66+
Some(Cmd::Tui {
67+
files,
68+
top,
69+
unique,
70+
enable_llm,
71+
llm_model,
72+
llm_which,
73+
}) => tui::run_tui_loop(files, top, unique, enable_llm, llm_model, llm_which),
5574
Some(Cmd::Search {
5675
files,
5776
query,

0 commit comments

Comments
 (0)