openai-term 1.4 → 2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openai +875 -68
  3. metadata +8 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8d8a11333a11c485b026674918f685ec1b0f5e0e085ffe9b36789cab50205dd
4
- data.tar.gz: 40d4fd48a98cd3134f7ba4ad19d8347246564aa6651fd2fb96202763d590e375
3
+ metadata.gz: d93eaecbf599141d1cf428a2c72f6de86e8c6770aff9af919cf438ff03f2c6cc
4
+ data.tar.gz: 5d335e40ae86757f7cf004b799dd7ebddf52f223a4f09e49ddf38dcadaae98ac
5
5
  SHA512:
6
- metadata.gz: 4b0a2e19261526b96af25f4015091a07091409325268b8816e2f2c8e95472b28cd318c84777dda8dca1009a8b2b96d309251320c8a2e0427ad21fc3749e2aa87
7
- data.tar.gz: e23f349349b402873ffc421c610fdb489c7db531bb533ba277cfb8ae140b1ba3277fbf67de01111dd796b999c35934f015893a69777c322bd204a2690fee5f28
6
+ metadata.gz: df381ff7515379eb62d4dcdd853e65ca677be5e31da91109ed1a86cc68bc1ea9ae063cd50048093e8ec922909c5a8e38896bda49bd03662e9037f3dfce6a23f0
7
+ data.tar.gz: bec34eec79d74a74ddca15e07ec5e23eec65839dd9873f5a927dc1daba855481363563d6e1e9c3632e52788dd9047e4cd221698f0953114403b3f094d0631ab5
data/bin/openai CHANGED
@@ -1,77 +1,884 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: utf-8
3
3
 
4
- # GET EXTENSIONS
4
+ # OpenAI Terminal Interface with rcurses
5
+ # A modern TUI for interacting with OpenAI's API
6
+
5
7
  require 'optparse'
6
- require 'tty-prompt'
7
- require "ruby/openai"
8
-
9
- # INITIALIZE CONSTANTS
10
- @x = 500
11
- @m = "gpt-3.5-turbo-instruct"
12
- @prompt = TTY::Prompt.new
13
-
14
- # HANDLE COMMAND LINE OPTIONS
15
- options = {}
16
- optparse = OptionParser.new do |opts|
17
- # Set a banner, displayed at the top of the help screen.
18
- opts.banner = "Usage: openai [options]"
19
-
20
- # Define the options, and what they do
21
- opts.on('-f', '--file textfile', 'A file to process') { |f| @f = f }
22
- opts.on('-t', '--text text', 'The text to process') { |t| @t = t }
23
- opts.on('-x', '--max max_tokens', 'Specify max number of words in response') { |x| @x = x.to_i }
24
- opts.on('-m', '--model', 'The AI model to use (default = gpt-3.5-turbo-instruct)') { |m| @m = m }
25
- opts.on('-M', '--listmodels', 'List available models, pick and use') { @m = "list" }
26
- opts.on('-i', '--image', 'Create an image with the text supplied by -t or -f') { @i = true }
27
- opts.on('-h', 'Display SHORT help text') { puts opts; exit }
28
- opts.on('-v', '--version', 'Display the version number') { puts "Version: 0.1"; exit }
29
- end
30
- optparse.parse!
31
-
32
- # READ USER CONF
33
- if File.exist?(Dir.home+'/.openai.conf')
34
- load(Dir.home+'/.openai.conf')
35
- else
36
- File.write(Dir.home+'/.openai.conf', "@ai = 'your-secret-openai-key'")
37
- puts "Edit '.openai.conf' in your home directory and edit in your secret openai key."
38
- puts "To retrieve such a key, create an account at beta.openai.com and get the key from your account menu (upper right on the web page)"
39
- end
40
-
41
- # PROCESS QUERY
42
- @q = ""
43
- @q += @t if @t
44
- @q += File.read(@f) if @f
45
- unless @f or @t
46
- puts "You must supply a query in form of a text file (option -f file) and/or text (option -t text)\n\n"
47
- exit
48
- end
49
-
50
- # REQUEST AND PRINT RESPONSE
51
- client = OpenAI::Client.new(access_token: @ai, log_errors: true)
52
-
53
- if @m == "list"
54
- c = client.models.list["data"].map.each { |i| i["id"] }
55
- @m = @prompt.select("What AI model do you want to use? (see https://beta.openai.com/docs/models/codex for details)", c, cycle: true)
56
- end
57
-
58
- begin
59
- if @i
60
- response = client.images.generate(parameters: { prompt: @q })
61
- puts "Image url:"
62
- puts response.dig("data", 0, "url")
8
+ require 'ruby/openai'
9
+ require 'rcurses'
10
+ require 'json'
11
+ require 'fileutils'
12
+
13
+ include Rcurses
14
+ include Rcurses::Input
15
+ include Rcurses::Cursor
16
+
17
+ # Constants
18
+ CONFIG_FILE = File.join(Dir.home, '.openai.conf')
19
+ HISTORY_FILE = File.join(Dir.home, '.openai_history.json')
20
+ DEFAULT_MODEL = "gpt-3.5-turbo"
21
+ DEFAULT_MAX_TOKENS = 2048
22
+ VERSION = "2.0"
23
+
24
+ # Global variables
25
+ @model = DEFAULT_MODEL
26
+ @max_tokens = DEFAULT_MAX_TOKENS
27
+ @temperature = 0.7
28
+ @conversation_history = []
29
+ @current_conversation = []
30
+ @api_key = nil
31
+ @client = nil
32
+
33
+ # UI elements
34
+ @header = nil
35
+ @chat_pane = nil
36
+ @input_pane = nil
37
+ @status_pane = nil
38
+ @model_list_pane = nil
39
+
40
+ # UI state
41
+ @mode = :chat # :chat, :model_select, :help
42
+ @input_text = ""
43
+ @chat_scroll = 0
44
+ @selected_model = 0
45
+
46
+ # Parse command line options
47
+ def parse_options
48
+ options = {}
49
+ optparse = OptionParser.new do |opts|
50
+ opts.banner = "Usage: openai [options]"
51
+
52
+ opts.on('-f', '--file FILE', 'Load initial query from file') { |f| options[:file] = f }
53
+ opts.on('-t', '--text TEXT', 'Initial query text') { |t| options[:text] = t }
54
+ opts.on('-m', '--model MODEL', "AI model (default: #{DEFAULT_MODEL})") { |m| @model = m }
55
+ opts.on('-x', '--max-tokens N', Integer, "Max tokens (default: #{DEFAULT_MAX_TOKENS})") { |x| @max_tokens = x }
56
+ opts.on('-T', '--temperature N', Float, 'Temperature 0-2 (default: 0.7)') { |t| @temperature = t }
57
+ opts.on('-i', '--image', 'Generate image instead of text') { options[:image] = true }
58
+ opts.on('-c', '--config FILE', 'Config file path') { |c| options[:config] = c }
59
+ opts.on('-q', '--quiet', 'Skip TUI and output to stdout directly') { options[:quiet] = true }
60
+ opts.on('-h', '--help', 'Display help') { puts opts; exit }
61
+ opts.on('-v', '--version', 'Display version') { puts "OpenAI Terminal 2.0"; exit }
62
+ end
63
+
64
+ optparse.parse!
65
+ options
66
+ end
67
+
68
+ # Load configuration
69
+ def load_config(config_path = nil)
70
+ config_file = config_path || CONFIG_FILE
71
+
72
+ if File.exist?(config_file)
73
+ load(config_file)
74
+ @api_key = @ai if defined?(@ai)
75
+ else
76
+ FileUtils.mkdir_p(File.dirname(config_file))
77
+ File.write(config_file, "@ai = 'your-secret-openai-key'")
78
+ puts "Created config file: #{config_file}"
79
+ puts "Please edit it and add your OpenAI API key."
80
+ puts "Get your key from: https://platform.openai.com/api-keys"
81
+ exit 1
82
+ end
83
+
84
+ unless @api_key && @api_key != 'your-secret-openai-key'
85
+ puts "Error: Invalid API key in #{config_file}"
86
+ puts "Please add your OpenAI API key to the config file."
87
+ exit 1
88
+ end
89
+ end
90
+
91
+ # Load conversation history
92
+ def load_history
93
+ return unless File.exist?(HISTORY_FILE)
94
+
95
+ begin
96
+ data = JSON.parse(File.read(HISTORY_FILE))
97
+ @conversation_history = data['conversations'] || []
98
+ @current_conversation = data['current'] || []
99
+
100
+ # Restore last used model if available
101
+ if data['last_model'] && !data['last_model'].empty?
102
+ @model = data['last_model']
103
+ end
104
+ rescue => e
105
+ @conversation_history = []
106
+ @current_conversation = []
107
+ end
108
+ end
109
+
110
+ # Save conversation history
111
+ def save_history
112
+ data = {
113
+ 'conversations' => @conversation_history.last(100), # Keep last 100 conversations
114
+ 'current' => @current_conversation.last(50), # Keep last 50 messages in current
115
+ 'last_model' => @model # Save the current model for next session
116
+ }
117
+
118
+ File.write(HISTORY_FILE, JSON.pretty_generate(data))
119
+ rescue => e
120
+ # Silently fail to not interrupt user experience
121
+ end
122
+
123
+ # Initialize OpenAI client
124
+ def init_client
125
+ @client = OpenAI::Client.new(
126
+ access_token: @api_key,
127
+ log_errors: false
128
+ )
129
+ end
130
+
131
+ # Setup UI
132
+ def setup_ui
133
+ rows, cols = IO.console.winsize
134
+
135
+ Rcurses.clear_screen
136
+ Cursor.hide
137
+
138
+ # Create panes - accounting for borders being drawn outside pane geometry
139
+ @header = Pane.new(1, 1, cols, 1, 255, 24)
140
+ @header.border = false # Top pane doesn't need border
141
+
142
+ @chat_pane = Pane.new(1, 3, cols, rows - 7, 255, 232)
143
+ @chat_pane.border = true
144
+
145
+ @input_pane = Pane.new(1, rows - 2, cols, 1, 255, 234)
146
+ @input_pane.border = true
147
+
148
+ @status_pane = Pane.new(1, rows, cols, 1, 255, 236)
149
+
150
+ # Popup panes (created but not displayed initially)
151
+ help_w = cols * 3 / 4
152
+ help_h = rows * 3 / 4
153
+ @help_pane = Pane.new((cols - help_w) / 2 + 1, (rows - help_h) / 2 + 1, help_w, help_h, 255, 234)
154
+ @help_pane.border = true
155
+
156
+ model_w = cols / 2
157
+ model_h = rows / 2
158
+ @model_list_pane = Pane.new((cols - model_w) / 2 + 1, (rows - model_h) / 2 + 1, model_w, model_h, 255, 233)
159
+ @model_list_pane.border = true
160
+
161
+ # Conversation list pane
162
+ conv_w = cols * 3 / 4
163
+ conv_h = rows * 3 / 4
164
+ @conversation_list_pane = Pane.new((cols - conv_w) / 2 + 1, (rows - conv_h) / 2 + 1, conv_w, conv_h, 255, 235)
165
+ @conversation_list_pane.border = true
166
+
167
+ # Popup state tracking
168
+ @help_visible = false
169
+ @model_select_visible = false
170
+ @conversation_list_visible = false
171
+ @selected_conversation = 0
172
+
173
+ update_header
174
+ update_status
175
+ refresh_all
176
+
177
+ # Ensure status pane is visible at startup and input pane is ready
178
+ @status_pane.refresh
179
+ @input_pane.text = ""
180
+ @input_pane.refresh
181
+ end
182
+
183
+ # Update header
184
+ def update_header
185
+ title = "OpenAI Terminal v2.0".b.fg(226)
186
+ model_info = "Model: #{@model}".fg(117)
187
+ tokens_info = "Max Tokens: #{@max_tokens}".fg(117)
188
+
189
+ @header.text = "#{title} | #{model_info} | #{tokens_info}"
190
+ @header.refresh
191
+ end
192
+
193
+ # Update status bar
194
+ def update_status
195
+ case @mode
196
+ when :chat
197
+ shortcuts = [
198
+ "C-Q:Quit",
199
+ "C-M:Models",
200
+ "C-H:Help",
201
+ "C-C:Clear",
202
+ "C-L:Load",
203
+ "C-S:Save",
204
+ "C-Y:Copy",
205
+ "C-V:Version",
206
+ "C-I:Image"
207
+ ].join(" ")
208
+ when :model_select
209
+ shortcuts = "↑↓:Navigate Enter:Select ESC:Cancel"
210
+ when :help
211
+ shortcuts = "Press any key to return"
212
+ end
213
+
214
+ @status_pane.text = " #{shortcuts}".fg(245)
215
+ @status_pane.refresh
216
+ end
217
+
218
+ # Refresh all panes
219
+ def refresh_all
220
+ @header.refresh
221
+ @chat_pane.refresh
222
+ @input_pane.refresh
223
+ @status_pane.refresh
224
+ end
225
+
226
+ # Add message to chat
227
+ def add_to_chat(role, content)
228
+ if role == "system"
229
+ # System messages don't get a prefix and don't go in conversation history
230
+ prefix = ""
231
+ add_to_history = false
232
+ else
233
+ prefix = role == "user" ? "You: ".b.fg(226) : "AI: ".b.fg(117)
234
+ add_to_history = true
235
+ end
236
+
237
+ # Format content with word wrapping
238
+ wrapped = word_wrap(content, @chat_pane.w - 6)
239
+ formatted = wrapped.lines.map.with_index do |line, i|
240
+ if i == 0 && !prefix.empty?
241
+ "#{prefix}#{line}"
242
+ else
243
+ prefix.empty? ? line : " #{line}"
244
+ end
245
+ end.join
246
+
247
+ # Add to chat
248
+ current_text = @chat_pane.text || ""
249
+ @chat_pane.text = current_text + formatted + "\n"
250
+
251
+ # Auto-scroll to bottom and force refresh
252
+ @chat_pane.bottom
253
+ @chat_pane.full_refresh # Use full_refresh to ensure immediate display
254
+
255
+ # Add to conversation history (but not system messages)
256
+ if add_to_history
257
+ @current_conversation << { "role" => role, "content" => content }
258
+ end
259
+ end
260
+
261
+ # Word wrap text
262
+ def word_wrap(text, width)
263
+ text.split("\n").map do |line|
264
+ if line.length <= width
265
+ line
266
+ else
267
+ line.scan(/.{1,#{width}}(?:\s|$)|.+/).join("\n")
268
+ end
269
+ end.join("\n")
270
+ end
271
+
272
+ # Send message to OpenAI (includes user message display)
273
+ def send_to_openai(message, generate_image = false)
274
+ add_to_chat("user", message) unless generate_image
275
+ get_openai_response(message, generate_image)
276
+ end
277
+
278
+ # Get response from OpenAI (without adding user message)
279
+ def get_openai_response(message, generate_image = false)
280
+ # Show thinking indicator
281
+ thinking = generate_image ? "Generating image...".i.fg(245) : "Thinking...".i.fg(245)
282
+ if @chat_pane
283
+ @chat_pane.text = (@chat_pane.text || "") + thinking + "\n"
284
+ @chat_pane.bottom
285
+ @chat_pane.full_refresh
63
286
  else
64
- response = client.completions( parameters: { model: @m, prompt: @q, max_tokens: @x })
65
- begin
66
- output = response["choices"][0]["text"]
67
- rescue => error
68
- p error
69
- output = response["error"]["message"]
287
+ puts thinking
288
+ end
289
+
290
+ begin
291
+ if generate_image
292
+ response = @client.images.generate(
293
+ parameters: {
294
+ prompt: message,
295
+ n: 1,
296
+ size: "1024x1024"
297
+ }
298
+ )
299
+
300
+ url = response.dig("data", 0, "url")
301
+ if url
302
+ content = "Image URL: #{url}"
303
+ else
304
+ content = "Error generating image"
305
+ end
306
+ elsif @model.include?("gpt")
307
+ # Prepare messages for chat models
308
+ messages = @current_conversation.map do |msg|
309
+ { role: msg["role"] == "user" ? "user" : "assistant", content: msg["content"] }
310
+ end
311
+
312
+ response = @client.chat(
313
+ parameters: {
314
+ model: @model,
315
+ messages: messages,
316
+ max_tokens: @max_tokens,
317
+ temperature: @temperature
318
+ }
319
+ )
320
+
321
+ content = response.dig("choices", 0, "message", "content")
322
+ else
323
+ # Use completion for older models
324
+ prompt = @current_conversation.map { |m| "#{m['role']}: #{m['content']}" }.join("\n") + "\nassistant:"
325
+
326
+ response = @client.completions(
327
+ parameters: {
328
+ model: @model,
329
+ prompt: prompt,
330
+ max_tokens: @max_tokens,
331
+ temperature: @temperature
332
+ }
333
+ )
334
+
335
+ content = response.dig("choices", 0, "text")
336
+ end
337
+
338
+ # Remove thinking indicator
339
+ if @chat_pane
340
+ @chat_pane.text = @chat_pane.text.lines[0...-1].join
341
+ end
342
+
343
+ if content
344
+ if @chat_pane
345
+ add_to_chat(generate_image ? "system" : "assistant", content.strip)
346
+ save_history unless generate_image
347
+ else
348
+ puts content.strip
349
+ end
350
+ else
351
+ error = response.dig("error", "message") || "Unknown error"
352
+ if @chat_pane
353
+ add_to_chat("system", "Error: #{error}".fg(196))
354
+ else
355
+ puts "Error: #{error}"
356
+ end
70
357
  end
71
- puts output.strip + "\n\n"
358
+
359
+ rescue => e
360
+ # Remove thinking indicator
361
+ if @chat_pane
362
+ @chat_pane.text = @chat_pane.text.lines[0...-1].join
363
+ add_to_chat("system", "Error: #{e.message}".fg(196))
364
+ else
365
+ puts "Error: #{e.message}"
366
+ end
367
+ end
368
+ end
369
+
370
+ # Show model selection popup
371
+ def show_model_selection
372
+ @model_select_visible = true
373
+
374
+ # Get available models
375
+ begin
376
+ models_response = @client.models.list
377
+ @available_models = models_response["data"]
378
+ .map { |m| m["id"] }
379
+ .select { |id| id.include?("gpt") || id.include?("davinci") || id.include?("curie") }
380
+ .sort
381
+ rescue => e
382
+ @available_models = [@model] # Fallback to current model
383
+ end
384
+
385
+ # Ensure selected model index is valid
386
+ @selected_model = 0 if @selected_model >= @available_models.size
387
+ @model_list_pane.ix = 0 # Reset scroll position
388
+
389
+ update_model_list
390
+ end
391
+
392
+ # Update model list display
393
+ def update_model_list
394
+ content = "Select Model (↑↓ to navigate, Enter to select, Esc to cancel):".b.fg(226) + "\n\n"
395
+ @available_models.each_with_index do |model, i|
396
+ if i == @selected_model
397
+ content += " → #{model}".fg(226).b + "\n"
398
+ else
399
+ content += " #{model}".fg(245) + "\n"
400
+ end
401
+ end
402
+
403
+ @model_list_pane.text = content
404
+ @model_list_pane.full_refresh
405
+ end
406
+
407
+ # Hide model selection popup
408
+ def hide_model_selection
409
+ @model_select_visible = false
410
+ Rcurses.clear_screen
411
+ [@header, @chat_pane, @input_pane, @status_pane].each(&:full_refresh)
412
+ end
413
+
414
+ # Show help popup
415
+ def show_help_popup
416
+ @help_visible = true
417
+
418
+ help_text = <<~HELP
419
+ #{"OpenAI Terminal Help".b.fg(226)}
420
+
421
+ #{"Keyboard Shortcuts:".b.fg(117)}
422
+
423
+ Ctrl-Q - Quit application
424
+ Ctrl-M - Select AI model
425
+ Ctrl-H - Show this help
426
+ Ctrl-C - Clear chat history
427
+ Ctrl-L - Load saved conversation
428
+ Ctrl-S - Save conversation to file
429
+ Ctrl-Y - Copy last AI response to clipboard
430
+ Ctrl-V - Show version information
431
+ Ctrl-I - Generate image
432
+ Any char - Start typing message
433
+
434
+ #{"Features:".b.fg(117)}
435
+
436
+ • Interactive chat with OpenAI
437
+ • Model selection
438
+ • Conversation history
439
+ • Auto-save conversations
440
+ • Configurable parameters
441
+
442
+ #{"Configuration:".b.fg(117)}
443
+
444
+ Config file: #{CONFIG_FILE}
445
+ History file: #{HISTORY_FILE}
446
+
447
+ Press ESC to close help...
448
+ HELP
449
+
450
+ @help_pane.text = help_text
451
+ @help_pane.ix = 0 # Reset scroll position
452
+ @help_pane.full_refresh
453
+ end
454
+
455
+ # Hide help popup
456
+ def hide_help_popup
457
+ @help_visible = false
458
+ Rcurses.clear_screen
459
+ [@header, @chat_pane, @input_pane, @status_pane].each(&:full_refresh)
460
+ end
461
+
462
+ # Navigate input history
463
+ def navigate_input_history(direction, input_history, current_index)
464
+ return if input_history.empty?
465
+
466
+ new_index = current_index + direction
467
+
468
+ if direction < 0 # UP - go to previous
469
+ new_index = [new_index, 0].max
470
+ else # DOWN - go to next or beyond (empty)
471
+ new_index = [new_index, input_history.size].min
472
+ end
473
+
474
+ @current_history_index = new_index
475
+
476
+ if new_index < input_history.size
477
+ # Show historical message
478
+ @input_pane.text = "You: #{input_history[new_index]}"
479
+ else
480
+ # Beyond history - empty input
481
+ @input_pane.text = "You: "
482
+ end
483
+
484
+ @input_pane.refresh
485
+ end
486
+
487
+ # Main input loop
488
+ def input_loop
489
+ input_history = []
490
+ history_index = 0
491
+ @current_history_index = 0
492
+
493
+ loop do
494
+ key = getchr
495
+
496
+
497
+ # Handle popup input first
498
+ if @help_visible
499
+ case key
500
+ when "ESC"
501
+ hide_help_popup
502
+ when "UP"
503
+ if @help_pane.ix > 0
504
+ @help_pane.ix -= 1
505
+ @help_pane.refresh
506
+ end
507
+ when "DOWN"
508
+ # Allow scrolling beyond visible content
509
+ total_lines = @help_pane.text.lines.count
510
+ visible_lines = @help_pane.h - 2 # Account for border
511
+ max_scroll = [total_lines - visible_lines, 0].max
512
+
513
+ if @help_pane.ix < max_scroll
514
+ @help_pane.ix += 1
515
+ @help_pane.refresh
516
+ end
517
+ end
518
+ next
519
+ end
520
+
521
+ if @model_select_visible
522
+ case key
523
+ when "UP"
524
+ if @selected_model > 0
525
+ @selected_model -= 1
526
+ # Scroll up if needed
527
+ visible_start = @model_list_pane.ix
528
+ if @selected_model < visible_start
529
+ @model_list_pane.ix = @selected_model
530
+ end
531
+ update_model_list
532
+ end
533
+ when "DOWN"
534
+ if @selected_model < @available_models.size - 1
535
+ @selected_model += 1
536
+ # Scroll down if needed
537
+ visible_lines = @model_list_pane.h - 4 # Account for border and header
538
+ visible_end = @model_list_pane.ix + visible_lines - 1
539
+ if @selected_model > visible_end
540
+ @model_list_pane.ix += 1
541
+ end
542
+ update_model_list
543
+ end
544
+ when "ENTER"
545
+ @model = @available_models[@selected_model]
546
+ save_history # Save the new model selection
547
+ update_header
548
+ hide_model_selection
549
+ when "ESC"
550
+ hide_model_selection
551
+ end
552
+ next
553
+ end
554
+
555
+ if @conversation_list_visible
556
+ case key
557
+ when "UP"
558
+ if @selected_conversation > 0
559
+ @selected_conversation -= 1
560
+ # Scroll up if needed
561
+ visible_start = @conversation_list_pane.ix
562
+ if @selected_conversation < visible_start
563
+ @conversation_list_pane.ix = @selected_conversation
564
+ end
565
+ update_conversation_list
566
+ end
567
+ when "DOWN"
568
+ if @selected_conversation < @conversation_history.size - 1
569
+ @selected_conversation += 1
570
+ # Scroll down if needed
571
+ visible_lines = @conversation_list_pane.h - 4 # Account for border and header
572
+ visible_end = @conversation_list_pane.ix + visible_lines - 1
573
+ if @selected_conversation > visible_end
574
+ @conversation_list_pane.ix += 1
575
+ end
576
+ update_conversation_list
577
+ end
578
+ when "ENTER"
579
+ load_selected_conversation
580
+ hide_conversation_list
581
+ when "ESC"
582
+ hide_conversation_list
583
+ end
584
+ next
585
+ end
586
+
587
+ # Normal input handling when no popups are visible
588
+ case key
589
+ when "C-Q"
590
+ break
591
+ when "ENTER" # C-M is actually ENTER in rcurses
592
+ show_model_selection
593
+ when "BACK" # C-H is actually BACK in rcurses
594
+ show_help_popup
595
+ when "C-C"
596
+ @current_conversation = []
597
+ @chat_pane.text = ""
598
+ @chat_pane.refresh
599
+ when "C-L"
600
+ show_conversation_list
601
+ when "C-S"
602
+ save_conversation
603
+ when "C-Y"
604
+ copy_last_ai_response
605
+ when "C-V"
606
+ show_version
607
+ when "UP"
608
+ navigate_input_history(-1, input_history, history_index)
609
+ history_index = @current_history_index
610
+ when "DOWN"
611
+ navigate_input_history(1, input_history, history_index)
612
+ history_index = @current_history_index
613
+ when "C-I"
614
+ # Image generation
615
+ @input_pane.prompt = "Image: "
616
+ @input_pane.text = ""
617
+ @input_pane.editline
618
+
619
+ # Only generate image if user didn't cancel (ESC)
620
+ final_text = @input_pane.text&.strip || ""
621
+ if final_text.length > 0
622
+ message = final_text
623
+ input_history << message
624
+ history_index = input_history.size
625
+ @current_history_index = history_index
626
+ send_to_openai(message, true)
627
+ end
628
+ # Reset input pane completely
629
+ @input_pane.clear
630
+ @input_pane.text = "You: "
631
+ @input_pane.full_refresh
632
+ else
633
+ # Any printable character -> Enter input pane editline
634
+ if key && key.length == 1 && key.match?(/[[:print:]]/)
635
+ # Set up for editline
636
+ @input_pane.prompt = "You: "
637
+ @input_pane.text = key
638
+ initial_text = key
639
+ @input_pane.editline
640
+
641
+ # After editline returns, check what happened
642
+ final_text = @input_pane.text&.strip || ""
643
+
644
+ # Check if editline was cancelled (ESC) by looking for specific patterns
645
+ # In rcurses, ESC typically leaves the text as-is but we need to detect cancellation
646
+ # We'll assume if the text is unchanged from initial OR empty, it was cancelled
647
+
648
+ # Only send if we have actual meaningful content that's different from initial
649
+ if final_text.length > 0 &&
650
+ final_text != initial_text &&
651
+ !final_text.empty?
652
+
653
+ message = final_text
654
+ input_history << message
655
+ history_index = input_history.size
656
+ @current_history_index = history_index
657
+
658
+ # Send message
659
+ add_to_chat("user", message)
660
+ get_openai_response(message, false)
661
+ end
662
+
663
+ # Always reset input pane completely (clears any remaining text)
664
+ @input_pane.text = "You: "
665
+ @input_pane.clear
666
+ @input_pane.text = "You: "
667
+ @input_pane.full_refresh
668
+ end
669
+ end
670
+ end
671
+ end
672
+
673
+ # Save current conversation
674
+ def save_conversation
675
+ return if @current_conversation.empty?
676
+
677
+ timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
678
+ @conversation_history << {
679
+ "timestamp" => timestamp,
680
+ "model" => @model,
681
+ "messages" => @current_conversation.dup
682
+ }
683
+ save_history
684
+
685
+ add_to_chat("system", "Conversation saved!".fg(118))
686
+ end
687
+
688
+ # Show conversation list popup
689
+ def show_conversation_list
690
+ if @conversation_history.empty?
691
+ add_to_chat("system", "No saved conversations found".fg(196))
692
+ return
693
+ end
694
+
695
+ @conversation_list_visible = true
696
+ @selected_conversation = 0
697
+ @conversation_list_pane.ix = 0 # Reset scroll position
698
+
699
+ update_conversation_list
700
+ end
701
+
702
+ # Update conversation list display
703
+ def update_conversation_list
704
+ content = "Load Conversation (↑↓ to navigate, Enter to load, Esc to cancel):".b.fg(226) + "\n\n"
705
+
706
+ @conversation_history.each_with_index do |conv, i|
707
+ timestamp = conv["timestamp"]
708
+ model = conv["model"]
709
+ message_count = conv["messages"].size
710
+
711
+ # Show first user message as preview
712
+ first_message = conv["messages"].find { |m| m["role"] == "user" }
713
+ preview = first_message ? first_message["content"][0..50] + "..." : "No messages"
714
+
715
+ line = "#{timestamp} | #{model} | #{message_count} msgs | #{preview}"
716
+
717
+ if i == @selected_conversation
718
+ content += " → #{line}".fg(226).b + "\n"
719
+ else
720
+ content += " #{line}".fg(245) + "\n"
721
+ end
722
+ end
723
+
724
+ @conversation_list_pane.text = content
725
+ @conversation_list_pane.full_refresh
726
+ end
727
+
728
+ # Load selected conversation
729
+ def load_selected_conversation
730
+ return if @selected_conversation >= @conversation_history.size
731
+
732
+ selected_conv = @conversation_history[@selected_conversation]
733
+ @current_conversation = selected_conv["messages"].dup
734
+
735
+ # Update chat display
736
+ chat_content = ""
737
+ @current_conversation.each do |msg|
738
+ prefix = msg["role"] == "user" ? "You: ".b.fg(226) : "AI: ".b.fg(117)
739
+ wrapped = word_wrap(msg["content"], @chat_pane.w - 6)
740
+ formatted = wrapped.lines.map.with_index do |line, i|
741
+ if i == 0
742
+ "#{prefix}#{line}"
743
+ else
744
+ " #{line}"
745
+ end
746
+ end.join
747
+ chat_content += formatted + "\n"
748
+ end
749
+
750
+ @chat_pane.text = chat_content
751
+ @chat_pane.bottom
752
+ @chat_pane.full_refresh
753
+
754
+ # Add system message directly to chat display (not to conversation history)
755
+ current_text = @chat_pane.text || ""
756
+ @chat_pane.text = current_text + "Conversation loaded!".fg(118) + "\n"
757
+ @chat_pane.bottom
758
+ @chat_pane.full_refresh
759
+ end
760
+
761
+ # Hide conversation list popup
762
+ def hide_conversation_list
763
+ @conversation_list_visible = false
764
+ Rcurses.clear_screen
765
+ [@header, @chat_pane, @input_pane, @status_pane].each(&:full_refresh)
766
+ end
767
+
768
+ # Show version information
769
+ def show_version
770
+ local_version = VERSION
771
+
772
+ begin
773
+ remote_version = Gem.latest_version_for('openai-term').version
774
+ version_info = "Local version: #{local_version}\n"
775
+ version_info += "Latest RubyGems version: #{remote_version}\n"
776
+
777
+ if Gem::Version.new(remote_version) > Gem::Version.new(local_version)
778
+ version_info += "Update available! Run: gem update openai-term".fg(226)
779
+ else
780
+ version_info += "You have the latest version!".fg(118)
781
+ end
782
+
783
+ version_info += "\n\nGem info: https://rubygems.org/gems/openai-term"
784
+ rescue StandardError => e
785
+ version_info = "Local version: #{local_version}\n"
786
+ version_info += "Could not check latest version: #{e.message}".fg(196)
787
+ version_info += "\n\nGem info: https://rubygems.org/gems/openai-term"
788
+ end
789
+
790
+ add_to_chat("system", version_info)
791
+ end
792
+
793
+ # Copy last AI response to clipboard
794
+ def copy_last_ai_response
795
+ # Find the last assistant message
796
+ last_ai_message = @current_conversation.reverse.find { |msg| msg["role"] == "assistant" }
797
+
798
+ if last_ai_message
799
+ content = last_ai_message["content"]
800
+
801
+ # Try different clipboard commands based on OS
802
+ clipboard_cmd = case RUBY_PLATFORM
803
+ when /darwin/
804
+ "pbcopy" # macOS
805
+ when /linux/
806
+ # Try xclip first, then xsel
807
+ system("which xclip > /dev/null 2>&1") ? "xclip -selection clipboard" : "xsel --clipboard --input"
808
+ when /mswin|mingw|cygwin/
809
+ "clip" # Windows
810
+ else
811
+ nil
812
+ end
813
+
814
+ if clipboard_cmd
815
+ begin
816
+ IO.popen(clipboard_cmd, 'w') { |io| io.write(content) }
817
+ add_to_chat("system", "AI response copied to clipboard!".fg(118))
818
+ rescue => e
819
+ add_to_chat("system", "Failed to copy to clipboard: #{e.message}".fg(196))
820
+ end
821
+ else
822
+ add_to_chat("system", "Clipboard not supported on this platform".fg(196))
823
+ end
824
+ else
825
+ add_to_chat("system", "No AI response to copy".fg(196))
826
+ end
827
+ end
828
+
829
+ # Process initial query
830
+ def process_initial_query(options)
831
+ initial_text = ""
832
+ initial_text += options[:text] if options[:text]
833
+ initial_text += File.read(options[:file]) if options[:file] && File.exist?(options[:file])
834
+
835
+ unless initial_text.empty?
836
+ if options[:quiet]
837
+ # Direct output mode
838
+ send_to_openai(initial_text, options[:image])
839
+ return :exit
840
+ else
841
+ send_to_openai(initial_text, options[:image])
842
+ end
843
+ end
844
+ nil
845
+ end
846
+
847
+ # Main program
848
+ def main
849
+ options = parse_options
850
+ load_config(options[:config])
851
+
852
+ # For quiet mode with initial query, skip UI entirely
853
+ if options[:quiet] && (options[:text] || options[:file])
854
+ init_client
855
+ initial_text = ""
856
+ initial_text += options[:text] if options[:text]
857
+ initial_text += File.read(options[:file]) if options[:file] && File.exist?(options[:file])
858
+ send_to_openai(initial_text, options[:image])
859
+ return
72
860
  end
73
- rescue => error
74
- p error
861
+
862
+ load_history
863
+ init_client
864
+ setup_ui
865
+
866
+ # Process initial query if provided
867
+ result = process_initial_query(options)
868
+ return if result == :exit
869
+
870
+ # Initialize input pane with prompt before starting loop
871
+ @input_pane.text = "You: "
872
+ @input_pane.refresh
873
+
874
+ # Main loop
875
+ input_loop
876
+
877
+ ensure
878
+ save_history if defined?(@conversation_history)
879
+ Cursor.show if defined?(Cursor)
880
+ Rcurses.clear_screen if defined?(Rcurses)
75
881
  end
76
882
 
77
- # vim: set sw=2 sts=2 et ft=ruby fdm=syntax fdn=2 fcs=fold\:\ :
883
+ # Run the program
884
+ main if __FILE__ == $0
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openai-term
3
3
  version: !ruby/object:Gem::Version
4
- version: '1.4'
4
+ version: '2.0'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-10-28 00:00:00.000000000 Z
11
+ date: 2025-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby-openai
@@ -25,25 +25,22 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: '3.0'
27
27
  - !ruby/object:Gem::Dependency
28
- name: tty-prompt
28
+ name: rcurses
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '0.23'
33
+ version: '3.5'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '0.23'
41
- description: 'This is a pretty straight forward interface to OpenAI with the option
42
- to select the AI model and the maximum token length (number of maximum words in
43
- the AI''s response). You will use the -t option to supply the query to OpenAI or
44
- the -f option to read the query from a text file instead. New in 1.3: Updated default
45
- model to gpt-3.5-turbo-instruct. 1.4: Added option -M to list available model and
46
- use the one selected.'
40
+ version: '3.5'
41
+ description: 'A modern terminal interface to OpenAI with a full TUI using rcurses.
42
+ Features include interactive chat mode, conversation history, model selection, and
43
+ more. Version 2.0: Complete rewrite using rcurses for a better terminal UI experience.'
47
44
  email: g@isene.com
48
45
  executables:
49
46
  - openai