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.
@@ -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