openai-term 1.4 → 2.1

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