legion-tty 0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE +201 -0
- data/README.md +138 -0
- data/exe/legion-tty +17 -0
- data/lib/legion/tty/app.rb +277 -0
- data/lib/legion/tty/background/github_probe.rb +357 -0
- data/lib/legion/tty/background/kerberos_probe.rb +205 -0
- data/lib/legion/tty/background/scanner.rb +160 -0
- data/lib/legion/tty/boot_logger.rb +34 -0
- data/lib/legion/tty/components/digital_rain.rb +138 -0
- data/lib/legion/tty/components/input_bar.rb +46 -0
- data/lib/legion/tty/components/markdown_view.rb +17 -0
- data/lib/legion/tty/components/message_stream.rb +86 -0
- data/lib/legion/tty/components/status_bar.rb +89 -0
- data/lib/legion/tty/components/token_tracker.rb +46 -0
- data/lib/legion/tty/components/tool_panel.rb +93 -0
- data/lib/legion/tty/components/wizard_prompt.rb +49 -0
- data/lib/legion/tty/hotkeys.rb +29 -0
- data/lib/legion/tty/screen_manager.rb +63 -0
- data/lib/legion/tty/screens/base.rb +28 -0
- data/lib/legion/tty/screens/chat.rb +428 -0
- data/lib/legion/tty/screens/dashboard.rb +211 -0
- data/lib/legion/tty/screens/onboarding.rb +463 -0
- data/lib/legion/tty/session_store.rb +74 -0
- data/lib/legion/tty/theme.rb +63 -0
- data/lib/legion/tty/version.rb +7 -0
- data/lib/legion/tty.rb +30 -0
- metadata +247 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../theme'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module TTY
|
|
7
|
+
module Components
|
|
8
|
+
class ToolPanel
|
|
9
|
+
ICONS = {
|
|
10
|
+
running: "\u27F3",
|
|
11
|
+
complete: "\u2713",
|
|
12
|
+
failed: "\u2717"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
STATUS_COLORS = {
|
|
16
|
+
running: :info,
|
|
17
|
+
complete: :success,
|
|
18
|
+
failed: :error
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# rubocop:disable Metrics/ParameterLists
|
|
22
|
+
def initialize(name:, args:, status: :running, duration: nil, result: nil, error: nil)
|
|
23
|
+
@name = name
|
|
24
|
+
@args = args
|
|
25
|
+
@status = status
|
|
26
|
+
@duration = duration
|
|
27
|
+
@result = result
|
|
28
|
+
@error = error
|
|
29
|
+
@expanded = status == :failed
|
|
30
|
+
end
|
|
31
|
+
# rubocop:enable Metrics/ParameterLists
|
|
32
|
+
|
|
33
|
+
def expanded?
|
|
34
|
+
@expanded
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def expand
|
|
38
|
+
@expanded = true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def collapse
|
|
42
|
+
@expanded = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def toggle
|
|
46
|
+
@expanded = !@expanded
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render(width: 80)
|
|
50
|
+
lines = [header_line(width)]
|
|
51
|
+
lines << body_line if @expanded && body_content
|
|
52
|
+
lines.join("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def header_line(width)
|
|
58
|
+
icon = ICONS.fetch(@status, '?')
|
|
59
|
+
color = STATUS_COLORS.fetch(@status, :muted)
|
|
60
|
+
icon_colored = Theme.c(color, icon)
|
|
61
|
+
name_colored = Theme.c(:accent, @name)
|
|
62
|
+
suffix = duration_text
|
|
63
|
+
line = "#{icon_colored} #{name_colored}#{suffix}"
|
|
64
|
+
plain_len = strip_ansi(line).length
|
|
65
|
+
line += ' ' * [width - plain_len, 0].max
|
|
66
|
+
line
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def duration_text
|
|
70
|
+
return '' unless @duration
|
|
71
|
+
|
|
72
|
+
Theme.c(:muted, " (#{format('%.2fs', @duration)})")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def body_content
|
|
76
|
+
return @error if @error
|
|
77
|
+
return @result if @result
|
|
78
|
+
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def body_line
|
|
83
|
+
content = body_content.to_s
|
|
84
|
+
Theme.c(:muted, " #{content}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def strip_ansi(str)
|
|
88
|
+
str.gsub(/\e\[[0-9;]*m/, '')
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-prompt'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module TTY
|
|
7
|
+
module Components
|
|
8
|
+
class WizardPrompt
|
|
9
|
+
PROVIDERS = {
|
|
10
|
+
'Claude (Anthropic)' => 'claude',
|
|
11
|
+
'OpenAI' => 'openai',
|
|
12
|
+
'Gemini (Google)' => 'gemini',
|
|
13
|
+
'Azure OpenAI' => 'azure',
|
|
14
|
+
'Local (Ollama/LM Studio)' => 'local'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(prompt: nil)
|
|
18
|
+
@prompt = prompt || ::TTY::Prompt.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ask_name
|
|
22
|
+
@prompt.ask('What should I call you?', required: true) { |q| q.modify(:strip) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ask_name_with_default(default)
|
|
26
|
+
@prompt.ask('What should I call you?', default: default) { |q| q.modify(:strip) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def select_provider
|
|
30
|
+
@prompt.select('Choose an AI provider:', PROVIDERS)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ask_api_key(provider:)
|
|
34
|
+
@prompt.mask("Enter API key for #{provider}:")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# rubocop:disable Naming/PredicateMethod
|
|
38
|
+
def confirm(question)
|
|
39
|
+
@prompt.yes?(question)
|
|
40
|
+
end
|
|
41
|
+
# rubocop:enable Naming/PredicateMethod
|
|
42
|
+
|
|
43
|
+
def select_from(question, choices)
|
|
44
|
+
@prompt.select(question, choices)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module TTY
|
|
5
|
+
class Hotkeys
|
|
6
|
+
def initialize
|
|
7
|
+
@bindings = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def register(key, description, &block)
|
|
11
|
+
@bindings[key] = { description: description, action: block }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# rubocop:disable Naming/PredicateMethod
|
|
15
|
+
def handle(key)
|
|
16
|
+
binding_entry = @bindings[key]
|
|
17
|
+
return false unless binding_entry
|
|
18
|
+
|
|
19
|
+
binding_entry[:action].call
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
# rubocop:enable Naming/PredicateMethod
|
|
23
|
+
|
|
24
|
+
def list
|
|
25
|
+
@bindings.map { |key, b| { key: key, description: b[:description] } }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module TTY
|
|
5
|
+
class ScreenManager
|
|
6
|
+
attr_reader :overlay
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@stack = []
|
|
10
|
+
@overlay = nil
|
|
11
|
+
@render_queue = Queue.new
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def push(screen)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
@stack.last&.deactivate
|
|
18
|
+
@stack.push(screen)
|
|
19
|
+
screen.activate
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def pop
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
return if @stack.size <= 1
|
|
26
|
+
|
|
27
|
+
screen = @stack.pop
|
|
28
|
+
screen.teardown
|
|
29
|
+
@stack.last&.activate
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def active_screen
|
|
34
|
+
@mutex.synchronize { @stack.last }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def show_overlay(overlay_obj)
|
|
38
|
+
@mutex.synchronize { @overlay = overlay_obj }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dismiss_overlay
|
|
42
|
+
@mutex.synchronize { @overlay = nil }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enqueue(update)
|
|
46
|
+
@render_queue.push(update)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def drain_queue
|
|
50
|
+
updates = []
|
|
51
|
+
updates << @render_queue.pop until @render_queue.empty?
|
|
52
|
+
updates
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def teardown_all
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@stack.reverse_each(&:teardown)
|
|
58
|
+
@stack.clear
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module TTY
|
|
5
|
+
module Screens
|
|
6
|
+
class Base
|
|
7
|
+
attr_reader :app
|
|
8
|
+
|
|
9
|
+
def initialize(app)
|
|
10
|
+
@app = app
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def activate; end
|
|
14
|
+
def deactivate; end
|
|
15
|
+
|
|
16
|
+
def render(_width, _height)
|
|
17
|
+
raise NotImplementedError, "#{self.class}#render must be implemented"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def handle_input(_key)
|
|
21
|
+
:pass
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def teardown; end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../screens/base'
|
|
4
|
+
require_relative '../components/message_stream'
|
|
5
|
+
require_relative '../components/status_bar'
|
|
6
|
+
require_relative '../components/input_bar'
|
|
7
|
+
require_relative '../components/token_tracker'
|
|
8
|
+
require_relative '../theme'
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module TTY
|
|
12
|
+
module Screens
|
|
13
|
+
# rubocop:disable Metrics/ClassLength
|
|
14
|
+
class Chat < Base
|
|
15
|
+
SLASH_COMMANDS = %w[/help /quit /clear /model /session /cost /export /tools /dashboard /hotkeys /save /load
|
|
16
|
+
/sessions].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :message_stream, :status_bar
|
|
19
|
+
|
|
20
|
+
def initialize(app, output: $stdout, input_bar: nil)
|
|
21
|
+
super(app)
|
|
22
|
+
@output = output
|
|
23
|
+
@message_stream = Components::MessageStream.new
|
|
24
|
+
@status_bar = Components::StatusBar.new
|
|
25
|
+
@running = false
|
|
26
|
+
@input_bar = input_bar || build_default_input_bar
|
|
27
|
+
@llm_chat = app.respond_to?(:llm_chat) ? app.llm_chat : nil
|
|
28
|
+
@token_tracker = Components::TokenTracker.new(provider: detect_provider)
|
|
29
|
+
@session_store = SessionStore.new
|
|
30
|
+
@session_name = 'default'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def activate
|
|
34
|
+
@running = true
|
|
35
|
+
cfg = safe_config
|
|
36
|
+
@status_bar.update(model: cfg[:provider], session: 'default')
|
|
37
|
+
setup_system_prompt
|
|
38
|
+
@message_stream.add_message(
|
|
39
|
+
role: :system,
|
|
40
|
+
content: "Welcome#{", #{cfg[:name]}" if cfg[:name]}. Type /help for commands."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def running?
|
|
45
|
+
@running
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run
|
|
49
|
+
activate
|
|
50
|
+
while @running
|
|
51
|
+
render_screen
|
|
52
|
+
input = read_input
|
|
53
|
+
break if input.nil?
|
|
54
|
+
|
|
55
|
+
result = handle_slash_command(input)
|
|
56
|
+
if result == :quit
|
|
57
|
+
auto_save_session
|
|
58
|
+
@running = false
|
|
59
|
+
break
|
|
60
|
+
elsif result.nil?
|
|
61
|
+
handle_user_message(input) unless input.strip.empty?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def handle_slash_command(input)
|
|
67
|
+
return nil unless input.start_with?('/')
|
|
68
|
+
|
|
69
|
+
cmd = input.split.first
|
|
70
|
+
return nil unless SLASH_COMMANDS.include?(cmd)
|
|
71
|
+
|
|
72
|
+
dispatch_slash(cmd, input)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def handle_user_message(input)
|
|
76
|
+
@message_stream.add_message(role: :user, content: input)
|
|
77
|
+
@message_stream.add_message(role: :assistant, content: '')
|
|
78
|
+
send_to_llm(input)
|
|
79
|
+
render_screen
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def send_to_llm(message)
|
|
83
|
+
unless @llm_chat
|
|
84
|
+
@message_stream.append_streaming(
|
|
85
|
+
'LLM not configured. Use /help for commands.'
|
|
86
|
+
)
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
response = @llm_chat.ask(message) do |chunk|
|
|
91
|
+
@message_stream.append_streaming(chunk.content) if chunk.content
|
|
92
|
+
render_screen
|
|
93
|
+
end
|
|
94
|
+
track_response_tokens(response)
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
@message_stream.append_streaming("\n[Error: #{e.message}]")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def render(width, height)
|
|
100
|
+
bar_line = @status_bar.render(width: width)
|
|
101
|
+
divider = Theme.c(:muted, '-' * width)
|
|
102
|
+
stream_height = [height - 2, 1].max
|
|
103
|
+
stream_lines = @message_stream.render(width: width, height: stream_height)
|
|
104
|
+
stream_lines + [divider, bar_line]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def handle_input(key)
|
|
108
|
+
case key
|
|
109
|
+
when :up
|
|
110
|
+
@message_stream.scroll_up
|
|
111
|
+
:handled
|
|
112
|
+
when :down
|
|
113
|
+
@message_stream.scroll_down
|
|
114
|
+
:handled
|
|
115
|
+
else
|
|
116
|
+
:pass
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def setup_system_prompt
|
|
123
|
+
cfg = safe_config
|
|
124
|
+
return unless @llm_chat && cfg.is_a?(Hash) && !cfg.empty?
|
|
125
|
+
|
|
126
|
+
prompt = build_system_prompt(cfg)
|
|
127
|
+
@llm_chat.with_instructions(prompt) if @llm_chat.respond_to?(:with_instructions)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
131
|
+
def build_system_prompt(cfg)
|
|
132
|
+
lines = ['You are Legion, an async cognition engine and AI assistant.']
|
|
133
|
+
lines << "The user's name is #{cfg[:name]}." if cfg[:name]
|
|
134
|
+
|
|
135
|
+
krb = cfg[:kerberos]
|
|
136
|
+
if krb.is_a?(Hash)
|
|
137
|
+
lines << "User identity: #{krb[:display_name]} (#{krb[:principal]})" if krb[:display_name]
|
|
138
|
+
lines << "Title: #{krb[:title]}" if krb[:title]
|
|
139
|
+
lines << "Department: #{krb[:department]}, Company: #{krb[:company]}" if krb[:department]
|
|
140
|
+
lines << "Location: #{[krb[:city], krb[:state]].compact.join(', ')}" if krb[:city]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
gh = cfg[:github]
|
|
144
|
+
if gh.is_a?(Hash) && gh[:username]
|
|
145
|
+
lines << "GitHub: #{gh[:username]}"
|
|
146
|
+
profile = gh[:profile]
|
|
147
|
+
if profile.is_a?(Hash) && profile[:public_repos]
|
|
148
|
+
lines << "GitHub repos: #{profile[:public_repos]} public, #{profile[:private_repos]} private"
|
|
149
|
+
end
|
|
150
|
+
orgs = gh[:orgs]
|
|
151
|
+
lines << "GitHub orgs: #{orgs.map { |o| o[:login] }.join(', ')}" if orgs.is_a?(Array) && !orgs.empty?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
env = cfg[:environment]
|
|
155
|
+
if env.is_a?(Hash)
|
|
156
|
+
lines << "Running services: #{env[:running_services].join(', ')}" if env[:running_services]&.any?
|
|
157
|
+
lines << "Repos: #{env[:repos_count]}" if env[:repos_count]
|
|
158
|
+
lines << "Top languages: #{env[:top_languages].keys.join(', ')}" if env[:top_languages]&.any?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
lines.join("\n")
|
|
162
|
+
end
|
|
163
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
164
|
+
|
|
165
|
+
def safe_config
|
|
166
|
+
return {} unless @app.respond_to?(:config)
|
|
167
|
+
|
|
168
|
+
cfg = @app.config
|
|
169
|
+
cfg.is_a?(Hash) ? cfg : {}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def render_screen
|
|
173
|
+
require 'tty-cursor'
|
|
174
|
+
lines = render(terminal_width, terminal_height - 1)
|
|
175
|
+
@output.print ::TTY::Cursor.move_to(0, 0)
|
|
176
|
+
@output.print ::TTY::Cursor.clear_screen_down
|
|
177
|
+
lines.each { |line| @output.puts line }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def read_input
|
|
181
|
+
return nil unless @input_bar.respond_to?(:read_line)
|
|
182
|
+
|
|
183
|
+
@input_bar.read_line
|
|
184
|
+
rescue Interrupt
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
189
|
+
def dispatch_slash(cmd, input)
|
|
190
|
+
case cmd
|
|
191
|
+
when '/quit' then :quit
|
|
192
|
+
when '/help' then handle_help
|
|
193
|
+
when '/clear' then handle_clear
|
|
194
|
+
when '/model' then handle_model(input)
|
|
195
|
+
when '/session' then handle_session(input)
|
|
196
|
+
when '/cost' then handle_cost
|
|
197
|
+
when '/export' then handle_export(input)
|
|
198
|
+
when '/tools' then handle_tools
|
|
199
|
+
when '/save' then handle_save(input)
|
|
200
|
+
when '/load' then handle_load(input)
|
|
201
|
+
when '/sessions' then handle_sessions
|
|
202
|
+
when '/dashboard' then handle_dashboard
|
|
203
|
+
when '/hotkeys' then handle_hotkeys
|
|
204
|
+
else :handled
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
208
|
+
|
|
209
|
+
def handle_help
|
|
210
|
+
@message_stream.add_message(
|
|
211
|
+
role: :system,
|
|
212
|
+
content: "Commands: /help /quit /clear /model <name> /session <name> /cost\n " \
|
|
213
|
+
'/export [md|json] /tools /dashboard /hotkeys /save /load /sessions'
|
|
214
|
+
)
|
|
215
|
+
:handled
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def handle_clear
|
|
219
|
+
@message_stream.messages.clear
|
|
220
|
+
:handled
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def handle_model(input)
|
|
224
|
+
name = input.split(nil, 2)[1]
|
|
225
|
+
if name
|
|
226
|
+
if @llm_chat.respond_to?(:with_model)
|
|
227
|
+
@llm_chat.with_model(name)
|
|
228
|
+
@status_bar.update(model: name)
|
|
229
|
+
@message_stream.add_message(role: :system, content: "Model switched to: #{name}")
|
|
230
|
+
else
|
|
231
|
+
@status_bar.update(model: name)
|
|
232
|
+
@message_stream.add_message(role: :system, content: "Model set to: #{name} (no active LLM session)")
|
|
233
|
+
end
|
|
234
|
+
else
|
|
235
|
+
current = safe_config[:provider] || 'unknown'
|
|
236
|
+
@message_stream.add_message(role: :system, content: "Current model: #{current}")
|
|
237
|
+
end
|
|
238
|
+
:handled
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def handle_session(input)
|
|
242
|
+
name = input.split(nil, 2)[1]
|
|
243
|
+
if name
|
|
244
|
+
@session_name = name
|
|
245
|
+
@status_bar.update(session: name)
|
|
246
|
+
end
|
|
247
|
+
:handled
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def handle_save(input)
|
|
251
|
+
name = input.split(nil, 2)[1] || @session_store.auto_session_name
|
|
252
|
+
@session_name = name
|
|
253
|
+
@session_store.save(name, messages: @message_stream.messages)
|
|
254
|
+
@status_bar.update(session: name)
|
|
255
|
+
@message_stream.add_message(role: :system, content: "Session saved as '#{name}'.")
|
|
256
|
+
:handled
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def handle_load(input)
|
|
260
|
+
name = input.split(nil, 2)[1]
|
|
261
|
+
unless name
|
|
262
|
+
@message_stream.add_message(role: :system, content: 'Usage: /load <session-name>')
|
|
263
|
+
return :handled
|
|
264
|
+
end
|
|
265
|
+
data = @session_store.load(name)
|
|
266
|
+
unless data
|
|
267
|
+
@message_stream.add_message(role: :system, content: "Session '#{name}' not found.")
|
|
268
|
+
return :handled
|
|
269
|
+
end
|
|
270
|
+
@message_stream.messages.replace(data[:messages])
|
|
271
|
+
@session_name = name
|
|
272
|
+
@status_bar.update(session: name)
|
|
273
|
+
@message_stream.add_message(role: :system,
|
|
274
|
+
content: "Session '#{name}' loaded (#{data[:messages].size} messages).")
|
|
275
|
+
:handled
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def handle_sessions
|
|
279
|
+
sessions = @session_store.list
|
|
280
|
+
if sessions.empty?
|
|
281
|
+
@message_stream.add_message(role: :system, content: 'No saved sessions.')
|
|
282
|
+
else
|
|
283
|
+
lines = sessions.map { |s| " #{s[:name]} - #{s[:message_count]} messages (#{s[:saved_at]})" }
|
|
284
|
+
@message_stream.add_message(role: :system, content: "Saved sessions:\n#{lines.join("\n")}")
|
|
285
|
+
end
|
|
286
|
+
:handled
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def auto_save_session
|
|
290
|
+
return if @message_stream.messages.empty?
|
|
291
|
+
|
|
292
|
+
@session_store.save(@session_name, messages: @message_stream.messages)
|
|
293
|
+
rescue StandardError
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def handle_cost
|
|
298
|
+
@message_stream.add_message(role: :system, content: @token_tracker.summary)
|
|
299
|
+
:handled
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# rubocop:disable Metrics/AbcSize
|
|
303
|
+
def handle_export(input)
|
|
304
|
+
require 'fileutils'
|
|
305
|
+
format = input.split[1]&.downcase
|
|
306
|
+
format = 'md' unless %w[json md].include?(format)
|
|
307
|
+
exports_dir = File.expand_path('~/.legionio/exports')
|
|
308
|
+
FileUtils.mkdir_p(exports_dir)
|
|
309
|
+
timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
|
|
310
|
+
path = File.join(exports_dir, "chat-#{timestamp}.#{format == 'json' ? 'json' : 'md'}")
|
|
311
|
+
if format == 'json'
|
|
312
|
+
export_json(path)
|
|
313
|
+
else
|
|
314
|
+
export_markdown(path)
|
|
315
|
+
end
|
|
316
|
+
@message_stream.add_message(role: :system, content: "Exported to: #{path}")
|
|
317
|
+
:handled
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
@message_stream.add_message(role: :system, content: "Export failed: #{e.message}")
|
|
320
|
+
:handled
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# rubocop:enable Metrics/AbcSize
|
|
324
|
+
|
|
325
|
+
# rubocop:disable Metrics/AbcSize
|
|
326
|
+
def handle_tools
|
|
327
|
+
lex_gems = Gem::Specification.select { |s| s.name.start_with?('lex-') }
|
|
328
|
+
if lex_gems.empty?
|
|
329
|
+
@message_stream.add_message(role: :system, content: 'No lex-* extensions found in loaded gems.')
|
|
330
|
+
else
|
|
331
|
+
lines = lex_gems.map do |spec|
|
|
332
|
+
loaded = $LOADED_FEATURES.any? { |f| f.include?(spec.name.tr('-', '/')) }
|
|
333
|
+
status = loaded ? '[loaded]' : '[available]'
|
|
334
|
+
" #{spec.name} #{spec.version} #{status}"
|
|
335
|
+
end
|
|
336
|
+
@message_stream.add_message(role: :system,
|
|
337
|
+
content: "LEX Extensions (#{lex_gems.size}):\n#{lines.join("\n")}")
|
|
338
|
+
end
|
|
339
|
+
:handled
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# rubocop:enable Metrics/AbcSize
|
|
343
|
+
|
|
344
|
+
def handle_dashboard
|
|
345
|
+
if @app.respond_to?(:toggle_dashboard)
|
|
346
|
+
@app.toggle_dashboard
|
|
347
|
+
else
|
|
348
|
+
@message_stream.add_message(role: :system, content: 'Dashboard not available.')
|
|
349
|
+
end
|
|
350
|
+
:handled
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def handle_hotkeys
|
|
354
|
+
if @app.respond_to?(:hotkeys)
|
|
355
|
+
bindings = @app.hotkeys.list
|
|
356
|
+
lines = bindings.map { |b| "#{b[:key].inspect} -> #{b[:description]}" }
|
|
357
|
+
text = lines.empty? ? 'No hotkeys registered.' : lines.join("\n")
|
|
358
|
+
@message_stream.add_message(role: :system, content: "Hotkeys:\n#{text}")
|
|
359
|
+
else
|
|
360
|
+
@message_stream.add_message(role: :system, content: 'Hotkeys not available.')
|
|
361
|
+
end
|
|
362
|
+
:handled
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def detect_provider
|
|
366
|
+
cfg = safe_config
|
|
367
|
+
provider = cfg[:provider].to_s.downcase
|
|
368
|
+
return provider if Components::TokenTracker::PRICING.key?(provider)
|
|
369
|
+
|
|
370
|
+
'claude'
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def track_response_tokens(response)
|
|
374
|
+
return unless response.respond_to?(:input_tokens)
|
|
375
|
+
|
|
376
|
+
@token_tracker.track(
|
|
377
|
+
input_tokens: response.input_tokens.to_i,
|
|
378
|
+
output_tokens: response.output_tokens.to_i
|
|
379
|
+
)
|
|
380
|
+
@status_bar.update(
|
|
381
|
+
tokens: @token_tracker.total_input_tokens + @token_tracker.total_output_tokens,
|
|
382
|
+
cost: @token_tracker.total_cost
|
|
383
|
+
)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def export_markdown(path)
|
|
387
|
+
lines = ["# Chat Export\n", "_Exported: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}_\n\n---\n"]
|
|
388
|
+
@message_stream.messages.each do |msg|
|
|
389
|
+
role_label = msg[:role].to_s.capitalize
|
|
390
|
+
lines << "\n**#{role_label}**\n\n#{msg[:content]}\n"
|
|
391
|
+
end
|
|
392
|
+
File.write(path, lines.join)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def export_json(path)
|
|
396
|
+
require 'json'
|
|
397
|
+
data = {
|
|
398
|
+
exported_at: Time.now.iso8601,
|
|
399
|
+
token_summary: @token_tracker.summary,
|
|
400
|
+
messages: @message_stream.messages.map { |m| { role: m[:role].to_s, content: m[:content] } }
|
|
401
|
+
}
|
|
402
|
+
File.write(path, ::JSON.pretty_generate(data))
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def build_default_input_bar
|
|
406
|
+
cfg = safe_config
|
|
407
|
+
name = cfg[:name] || 'User'
|
|
408
|
+
Components::InputBar.new(name: name)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def terminal_width
|
|
412
|
+
require 'tty-screen'
|
|
413
|
+
::TTY::Screen.width
|
|
414
|
+
rescue StandardError
|
|
415
|
+
80
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def terminal_height
|
|
419
|
+
require 'tty-screen'
|
|
420
|
+
::TTY::Screen.height
|
|
421
|
+
rescue StandardError
|
|
422
|
+
24
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
# rubocop:enable Metrics/ClassLength
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|