@@ -3,17 +3,21 @@ use anyhow::{bail, Context, Result};
33use directories:: UserDirs ;
44use fuzzy_matcher:: skim:: SkimMatcherV2 ;
55use fuzzy_matcher:: FuzzyMatcher ;
6+ use hex:: encode;
67use libsql:: Value ;
78use once_cell:: sync:: Lazy ;
89use ratatui:: layout:: Rect ;
10+ use sha2:: { Digest , Sha256 } ;
911use std:: cmp:: Ordering ;
1012use std:: fs:: File ;
1113use std:: io:: Read ;
1214use std:: path:: { Path , PathBuf } ;
1315
1416use 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
1822static 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
102109impl 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
198256fn 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 ) ]
220354struct FuzzyHistoryModel {
221355 corpus : Vec < String > ,
0 commit comments