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.
- checksums.yaml +4 -4
- data/bin/openai +925 -68
- metadata +9 -11
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e6298f3f1e4de53454d7d6503f2f790e10ee94a2bb49d3adea4a73a880e3fe93
|
4
|
+
data.tar.gz: 7065017d5738ed54d6d726f696d86a88c4f84832432acd67fe196f7cc788cf4f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
#
|
4
|
+
# OpenAI Terminal Interface with rcurses
|
5
|
+
# A modern TUI for interacting with OpenAI's API
|
6
|
+
|
5
7
|
require 'optparse'
|
6
|
-
require '
|
7
|
-
require
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
@
|
43
|
-
@
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
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
|
-
|
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
|
-
|
74
|
-
|
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
|
-
#
|
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
|
+
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:
|
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:
|
28
|
+
name: rcurses
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: '
|
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: '
|
41
|
-
description: '
|
42
|
-
|
43
|
-
|
44
|
-
|
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
|