2025-04-26 21:20:58 +00:00
#/bin/python
# AI Assistant in the terminal
import argparse
import os
import sys
import json
2025-04-11 05:19:01 +00:00
from ollama import Client
import re
import pyperclip
2025-04-26 21:20:58 +00:00
2025-04-11 05:19:01 +00:00
import pygments
from pygments . lexers import get_lexer_by_name , guess_lexer
from pygments . formatters import TerminalFormatter
2025-04-26 21:20:58 +00:00
from prompt_toolkit . key_binding import KeyBindings
from prompt_toolkit import PromptSession
2025-04-11 05:19:01 +00:00
class AIAssistant :
def __init__ ( self , server = " http://localhost:11434 " , model = " gemma3:12b " ) :
self . server = server
self . model = model
self . client = Client ( host = self . server )
self . temperature = 0.2
self . num_ctx = 4096
self . history = [ self . system_prompt ( ) ]
def set_host ( self , host ) :
self . server = host
self . client = Client ( host = host )
def system_prompt ( self ) :
return { " role " : " system " , " content " : " You are a helpful, smart, kind, and efficient AI assistant. You always fulfill the user ' s requests accurately and concisely. " }
def load_history ( self ) :
path = os . environ . get ( ' HOME ' ) + ' /.cache/ai-assistant.history '
try :
with open ( path , ' r ' ) as f :
self . history = json . load ( f )
except FileNotFoundError :
pass
def save_history ( self ) :
path = os . environ . get ( ' HOME ' ) + ' /.cache/ai-assistant.history '
with open ( path , ' w+ ' ) as f :
json . dump ( self . history , f )
2025-04-26 21:20:58 +00:00
def determine_lexer ( self , code_block ) :
lexer_name = None
lines = code_block . split ( ' \n ' )
for line in lines :
if line . strip ( ) . startswith ( ' ``` ' ) :
lexer_part = line . strip ( ) . split ( ' ``` ' ) [ 1 ] . strip ( )
if lexer_part :
lexer_name = lexer_part
break
elif line . strip ( ) . startswith ( ' lang: ' ) :
lexer_part = line . strip ( ) . split ( ' : ' ) [ 1 ] . strip ( )
if lexer_part :
lexer_name = lexer_part
break
return lexer_name
def highlight_code ( self , lexer_name , code ) :
try :
lexer = get_lexer_by_name ( lexer_name ) if lexer_name else guess_lexer ( code )
except ValueError :
lexer = guess_lexer ( ' \n ' . join ( code . split ( ' \n ' ) [ 1 : - 1 ] ) )
if not lexer :
lexer = get_lexer_by_name ( ' bash ' )
formatter = TerminalFormatter ( )
highlighted_code = pygments . highlight ( code , lexer , formatter )
return highlighted_code
2025-04-11 05:19:01 +00:00
def chat ( self , message , stream = True ) :
self . history . append ( { " role " : " user " , " content " : message } )
completion = self . client . chat (
model = self . model ,
options = { " temperature " : self . temperature , " num_ctx " : self . num_ctx } ,
messages = self . history ,
stream = stream
)
result = ' '
large_chunk = [ ]
2025-04-26 21:20:58 +00:00
language = None
2025-04-11 05:19:01 +00:00
for chunk in completion :
text = chunk [ ' message ' ] [ ' content ' ]
large_chunk . append ( text )
2025-04-26 21:20:58 +00:00
large_text = ' ' . join ( large_chunk )
if ( ' ``` ' in large_text ) and ( ' \n ' in large_text . split ( ' ``` ' ) [ 1 ] ) :
language = large_text . split ( ' ``` ' ) [ 1 ] . split ( ' \n ' ) [ 0 ]
large_chunk = [ ]
if language == ' ' :
print ( large_text , end = ' ' , flush = True )
language = None
2025-04-11 05:19:01 +00:00
if stream :
2025-04-26 21:20:58 +00:00
if language and ( ' \n ' in large_text ) and large_chunk :
output = self . highlight_code ( language , large_text )
print ( output , end = ' ' , flush = True )
large_chunk = [ ]
elif not language or not large_chunk :
print ( text , end = ' ' , flush = True )
2025-04-11 05:19:01 +00:00
if not stream :
result = completion [ ' message ' ] [ ' content ' ]
else :
result = ' ' . join ( large_chunk )
self . history . append ( { " role " : ' assistant ' , ' content ' : result } )
self . save_history ( )
return result
class CommandLineParser :
def __init__ ( self ) :
self . parser = argparse . ArgumentParser ( description = ' Chat with an intelligent assistant ' )
self . add_arguments ( )
def add_arguments ( self ) :
parser = self . parser
parser . add_argument ( ' --host ' , nargs = ' ? ' , const = True , default = False , help = ' Specify host of Ollama server ' )
parser . add_argument ( ' --model ' , ' -m ' , nargs = ' ? ' , const = True , default = False , help = ' Specify model ' )
parser . add_argument ( ' --temp ' , ' -t ' , nargs = ' ? ' , type = float , const = 0.2 , default = False , help = ' Specify temperature ' )
parser . add_argument ( ' --context ' , type = int , default = 4096 , help = ' Specify context size ' )
parser . add_argument ( ' --reasoning ' , ' -r ' , action = ' store_true ' , help = ' Use the default reasoning model deepseek-r1:14b ' )
parser . add_argument ( ' --new ' , ' -n ' , action = ' store_true ' , help = ' Start a chat with a fresh history ' )
parser . add_argument ( ' --follow-up ' , ' -f ' , nargs = ' ? ' , const = True , default = False , help = ' Ask a follow up question when piping in context ' )
parser . add_argument ( ' --copy ' , ' -c ' , action = ' store_true ' , help = ' Copy a codeblock if it appears ' )
parser . add_argument ( ' --shell ' , ' -s ' , nargs = ' ? ' , const = True , default = False , help = ' Output a shell command that does as described ' )
def parse ( self ) :
return self . parser . parse_args ( )
2025-04-26 21:20:58 +00:00
# Keybindings
bindings = KeyBindings ( )
@bindings.add ( ' c-d ' )
def _ ( event ) :
event . current_buffer . validate_and_handle ( )
2025-04-11 05:19:01 +00:00
class InputHandler :
def __init__ ( self , assistant ) :
self . assistant = assistant
2025-04-26 21:20:58 +00:00
self . session = PromptSession ( multiline = True , prompt_continuation = ' ' , key_bindings = bindings )
2025-04-11 05:19:01 +00:00
def handle_input ( self , args ) :
if not sys . stdin . isatty ( ) :
self . handle_piped_input ( args )
else :
self . handle_interactive_input ( args )
def handle_piped_input ( self , args ) :
all_input = sys . stdin . read ( )
query = f ' Use the following context to answer the question. There will be no follow up questions from the user so make sure your answer is complete: \n { all_input } \n '
if args . copy :
query + = ' Answer the question using a codeblock for any code or shell scripts \n '
if args . follow_up :
2025-04-26 21:20:58 +00:00
second_input = improved_input ( )
2025-04-11 05:19:01 +00:00
query + = f ' \n { second_input } '
result = self . assistant . chat ( query , stream = False )
blocks = self . extract_code_block ( result )
if args . copy and len ( blocks ) :
pyperclip . copy ( blocks [ 0 ] )
2025-04-26 21:20:58 +00:00
def arg_shell ( args ) :
query = '''
Form a shell command based on the following description . Only output a working shell command . Format the command like this : ` command `
Description :
'''
if args . shell != True :
query + = args . shell
else :
query + = self . improved_input ( )
result = self . assistant . chat ( query , stream = False )
result = blocks [ 0 ] if len ( blocks := self . extract_code_block ( result ) ) else result
print ( result )
copy_string_to_clipboard ( result )
2025-04-11 05:19:01 +00:00
def handle_interactive_input ( self , args ) :
2025-04-26 21:20:58 +00:00
if args . shell :
self . arg_shell ( args )
exit ( )
2025-04-11 05:19:01 +00:00
print ( " \033 [91massistant \033 [0m: Type your message (press Ctrl+D to send): " )
while True :
try :
full_input = self . improved_input ( )
if full_input is None :
break
if full_input . strip ( ) == ' ' :
continue
result = self . assistant . chat ( full_input )
print ( )
except ( EOFError , KeyboardInterrupt ) :
print ( " \n Exiting... " )
break
2025-04-26 21:20:58 +00:00
def improved_input ( self , prompt = " > " ) :
"""
Returns the full text ( including embedded newlines ) when you press Ctrl - D .
Arrow keys edit within or across lines automatically .
"""
try :
text = self . session . prompt ( prompt )
return text
except KeyboardInterrupt :
print ( " \n User aborted input " )
return None
2025-04-11 05:19:01 +00:00
def extract_code_block ( self , text ) :
pattern = r ' ```[a-z]* \ n[ \ s \ S]*? \ n``` '
code_blocks = [ ]
matches = re . finditer ( pattern , text )
for match in matches :
code_block = match . group ( 0 )
2025-04-26 21:20:58 +00:00
lexer_name = self . assistant . determine_lexer ( code_block )
highlighted_code = self . assistant . highlight_code ( lexer_name , code_block )
2025-04-11 05:19:01 +00:00
code_blocks . append ( highlighted_code )
if not code_blocks :
line_pattern = r ' `[a-z]*[ \ s \ S]*?` '
line_matches = re . finditer ( line_pattern , text )
for match in line_matches :
code_block = match . group ( 0 )
code_blocks . append ( code_block [ 1 : - 1 ] )
return code_blocks
class CommandParser :
def __init__ ( self ) :
self . commands = {
' /save ' : self . handle_save ,
' /clear ' : self . handle_clear ,
' /clipboard ' : self . handle_clipboard ,
' /exit ' : self . handle_exit
}
def parse_commands ( self , text ) :
tokens = text . split ( ' ' )
if not tokens :
return False
command = tokens [ 0 ]
if command in self . commands :
handler = self . commands [ command ]
if len ( tokens ) > 1 and command == ' /clipboard ' :
context_query = ' \n \n The following is context provided by the user: \n '
clipboard_content = pyperclip . paste ( )
if clipboard_content :
context_query + = clipboard_content + ' \n '
return handler ( context_query )
else :
handler ( )
return True
return False
def handle_save ( self ) :
filename = input ( ' Enter filename to save conversation: ' )
self . save_conversation ( filename )
def save_conversation ( self , filename = ' conversation.md ' ) :
if not filename . endswith ( ' .md ' ) :
filename + = ' .md '
base , extension = os . path . splitext ( filename )
i = 1
while os . path . exists ( filename ) :
filename = f " { base } _ { i } { extension } "
i + = 1
with open ( filename , ' w ' ) as f :
f . write ( conversation )
def handle_clear ( self ) :
self . assistant . history = [ self . assistant . system_prompt ( ) ]
self . assistant . save_history ( )
def handle_clipboard ( self , context_query ) :
# Implementation for clipboard command
pass
def handle_exit ( self ) :
sys . exit ( 0 )
def main ( ) :
args = CommandLineParser ( ) . parse ( )
assistant = AIAssistant ( )
if args . host :
assistant . set_host ( args . host )
if args . model :
assistant . model = args . model
if args . temp :
assistant . temperature = args . temp
if args . context :
assistant . num_ctx = args . context
if args . new :
assistant . history = [ assistant . system_prompt ( ) ]
assistant . save_history ( )
2025-04-26 21:20:58 +00:00
else :
assistant . load_history ( )
2025-04-11 05:19:01 +00:00
input_handler = InputHandler ( assistant )
input_handler . handle_input ( args )
if __name__ == ' __main__ ' :
main ( )