legion-tty 0.4.11 → 0.4.13

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.
@@ -6,16 +6,29 @@ require_relative '../components/status_bar'
6
6
  require_relative '../components/input_bar'
7
7
  require_relative '../components/token_tracker'
8
8
  require_relative '../theme'
9
+ require_relative 'chat/session_commands'
10
+ require_relative 'chat/export_commands'
11
+ require_relative 'chat/message_commands'
12
+ require_relative 'chat/ui_commands'
13
+ require_relative 'chat/model_commands'
14
+ require_relative 'chat/custom_commands'
9
15
 
10
16
  module Legion
11
17
  module TTY
12
18
  module Screens
13
19
  # rubocop:disable Metrics/ClassLength
14
20
  class Chat < Base
21
+ include SessionCommands
22
+ include ExportCommands
23
+ include MessageCommands
24
+ include UiCommands
25
+ include ModelCommands
26
+ include CustomCommands
27
+
15
28
  SLASH_COMMANDS = %w[/help /quit /clear /compact /copy /diff /model /session /cost /export /tools /dashboard
16
29
  /hotkeys /save /load /sessions /system /delete /plan /palette /extensions /config
17
- /theme /search /stats /personality /undo /history /pin /pins /rename
18
- /context /alias /snippet /debug /uptime /bookmark].freeze
30
+ /theme /search /grep /stats /personality /undo /history /pin /pins /rename
31
+ /context /alias /snippet /debug /uptime /time /bookmark /welcome /tips].freeze
19
32
 
20
33
  PERSONALITIES = {
21
34
  'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
@@ -58,6 +71,7 @@ module Legion
58
71
  role: :system,
59
72
  content: "Welcome#{", #{cfg[:name]}" if cfg[:name]}. Type /help for commands."
60
73
  )
74
+ @status_bar.update(message_count: @message_stream.messages.size)
61
75
  end
62
76
 
63
77
  def running?
@@ -111,6 +125,7 @@ module Legion
111
125
  @message_stream.add_message(role: :assistant, content: '')
112
126
  send_to_llm(input)
113
127
  end
128
+ @status_bar.update(message_count: @message_stream.messages.size)
114
129
  render_screen
115
130
  end
116
131
 
@@ -308,6 +323,7 @@ module Legion
308
323
  when '/config' then handle_config_screen
309
324
  when '/theme' then handle_theme(input)
310
325
  when '/search' then handle_search(input)
326
+ when '/grep' then handle_grep(input)
311
327
  when '/stats' then handle_stats
312
328
  when '/personality' then handle_personality(input)
313
329
  when '/undo' then handle_undo
@@ -320,216 +336,20 @@ module Legion
320
336
  when '/snippet' then handle_snippet(input)
321
337
  when '/debug' then handle_debug
322
338
  when '/uptime' then handle_uptime
339
+ when '/time' then handle_time
323
340
  when '/bookmark' then handle_bookmark
341
+ when '/welcome' then handle_welcome
342
+ when '/tips' then handle_tips
324
343
  else :handled
325
344
  end
326
345
  end
327
346
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
328
347
 
329
- # rubocop:disable Metrics/MethodLength
330
- def handle_help
331
- @message_stream.add_message(
332
- role: :system,
333
- content: "Commands:\n /help /quit /clear /model <name> /session <name> /cost\n " \
334
- "/export [md|json] /tools /dashboard /hotkeys /save /load /sessions\n " \
335
- "/system <prompt> /delete <session> /plan /palette /extensions /config\n " \
336
- "/theme [name] -- switch color theme (purple, green, blue, amber)\n " \
337
- "/search <text> -- search message history\n " \
338
- "/compact [n] -- keep last n message pairs (default 5)\n " \
339
- "/copy -- copy last assistant message to clipboard\n " \
340
- "/diff -- show new messages since session was loaded\n " \
341
- "/stats -- show conversation statistics\n " \
342
- "/personality [name] -- switch assistant personality\n " \
343
- "/undo -- remove last user+assistant message pair\n " \
344
- "/history -- show recent input history\n " \
345
- "/pin [N] -- pin last assistant message (or message at index N)\n " \
346
- "/pins -- show all pinned messages\n " \
347
- "/rename <name> -- rename current session\n " \
348
- "/context -- show active session context summary\n " \
349
- "/alias [shortname /command] -- create or list command aliases\n " \
350
- "/snippet save|load|list|delete <name> -- manage reusable text snippets\n " \
351
- "/debug -- toggle debug mode (shows internal state)\n " \
352
- "/uptime -- show how long this session has been active\n " \
353
- "/bookmark -- export pinned messages to a markdown file\n\n" \
354
- 'Hotkeys: Ctrl+D=dashboard Ctrl+K=palette Ctrl+S=sessions Esc=back'
355
- )
356
- :handled
357
- end
358
- # rubocop:enable Metrics/MethodLength
359
-
360
- def handle_clear
361
- @message_stream.messages.clear
362
- :handled
363
- end
364
-
365
- def handle_model(input)
366
- name = input.split(nil, 2)[1]
367
- if name
368
- switch_model(name)
369
- else
370
- show_current_model
371
- end
372
- :handled
373
- end
374
-
375
- def switch_model(name)
376
- unless @llm_chat
377
- @message_stream.add_message(role: :system, content: 'No active LLM session.')
378
- return
379
- end
380
-
381
- apply_model_switch(name)
382
- rescue StandardError => e
383
- @message_stream.add_message(role: :system, content: "Failed to switch model: #{e.message}")
384
- end
385
-
386
- def apply_model_switch(name)
387
- new_chat = try_provider_switch(name)
388
- if new_chat
389
- @llm_chat = new_chat
390
- @status_bar.update(model: name)
391
- @token_tracker.update_model(name)
392
- @message_stream.add_message(role: :system, content: "Switched to provider: #{name}")
393
- elsif @llm_chat.respond_to?(:with_model)
394
- @llm_chat.with_model(name)
395
- @status_bar.update(model: name)
396
- @token_tracker.update_model(name)
397
- @message_stream.add_message(role: :system, content: "Model switched to: #{name}")
398
- else
399
- @status_bar.update(model: name)
400
- @message_stream.add_message(role: :system, content: "Model set to: #{name}")
401
- end
402
- end
403
-
404
- def try_provider_switch(name)
405
- return nil unless defined?(Legion::LLM)
406
-
407
- providers = Legion::LLM.settings[:providers]
408
- return nil unless providers.is_a?(Hash) && providers.key?(name.to_sym)
409
-
410
- Legion::LLM.chat(provider: name)
411
- rescue StandardError
412
- nil
413
- end
414
-
415
- def open_model_picker
416
- require_relative '../components/model_picker'
417
- picker = Components::ModelPicker.new(
418
- current_provider: safe_config[:provider],
419
- current_model: @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : nil
420
- )
421
- selection = picker.select_with_prompt(output: @output)
422
- return unless selection
423
-
424
- switch_model(selection[:provider])
425
- end
426
-
427
- def show_current_model
428
- model = @llm_chat.respond_to?(:model) ? @llm_chat.model : nil
429
- provider = safe_config[:provider] || 'unknown'
430
- info = model ? "#{model} (#{provider})" : provider
431
- @message_stream.add_message(role: :system, content: "Current model: #{info}")
432
- end
433
-
434
- def handle_session(input)
435
- name = input.split(nil, 2)[1]
436
- if name
437
- @session_name = name
438
- @status_bar.update(session: name)
439
- end
440
- :handled
441
- end
442
-
443
- def handle_save(input)
444
- name = input.split(nil, 2)[1] || @session_store.auto_session_name
445
- @session_name = name
446
- @session_store.save(name, messages: @message_stream.messages)
447
- @status_bar.update(session: name)
448
- @status_bar.notify(message: "Saved '#{name}'", level: :success, ttl: 3)
449
- @message_stream.add_message(role: :system, content: "Session saved as '#{name}'.")
450
- :handled
451
- end
452
-
453
- def handle_load(input)
454
- name = input.split(nil, 2)[1]
455
- unless name
456
- @message_stream.add_message(role: :system, content: 'Usage: /load <session-name>')
457
- return :handled
458
- end
459
- data = @session_store.load(name)
460
- unless data
461
- @message_stream.add_message(role: :system, content: "Session '#{name}' not found.")
462
- return :handled
463
- end
464
- @message_stream.messages.replace(data[:messages])
465
- @loaded_message_count = @message_stream.messages.size
466
- @session_name = name
467
- @status_bar.update(session: name)
468
- @status_bar.notify(message: "Loaded '#{name}'", level: :info, ttl: 3)
469
- @message_stream.add_message(role: :system,
470
- content: "Session '#{name}' loaded (#{data[:messages].size} messages).")
471
- :handled
472
- end
473
-
474
- def handle_sessions
475
- sessions = @session_store.list
476
- if sessions.empty?
477
- @message_stream.add_message(role: :system, content: 'No saved sessions.')
478
- else
479
- lines = sessions.map { |s| " #{s[:name]} - #{s[:message_count]} messages (#{s[:saved_at]})" }
480
- @message_stream.add_message(role: :system, content: "Saved sessions:\n#{lines.join("\n")}")
481
- end
482
- :handled
483
- end
484
-
485
- def auto_save_session
486
- return if @message_stream.messages.empty?
487
-
488
- if @session_name == 'default'
489
- @session_name = @session_store.auto_session_name(messages: @message_stream.messages)
490
- end
491
- @session_store.save(@session_name, messages: @message_stream.messages)
492
- rescue StandardError
493
- nil
494
- end
495
-
496
348
  def handle_cost
497
349
  @message_stream.add_message(role: :system, content: @token_tracker.summary)
498
350
  :handled
499
351
  end
500
352
 
501
- def handle_export(input)
502
- require 'fileutils'
503
- path = build_export_path(input)
504
- dispatch_export(path, input.split[1]&.downcase)
505
- @status_bar.notify(message: 'Exported', level: :success, ttl: 3)
506
- @message_stream.add_message(role: :system, content: "Exported to: #{path}")
507
- :handled
508
- rescue StandardError => e
509
- @message_stream.add_message(role: :system, content: "Export failed: #{e.message}")
510
- :handled
511
- end
512
-
513
- def build_export_path(input)
514
- format = input.split[1]&.downcase
515
- format = 'md' unless %w[json md html].include?(format)
516
- exports_dir = File.expand_path('~/.legionio/exports')
517
- FileUtils.mkdir_p(exports_dir)
518
- timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
519
- ext = { 'json' => 'json', 'md' => 'md', 'html' => 'html' }[format]
520
- File.join(exports_dir, "chat-#{timestamp}.#{ext}")
521
- end
522
-
523
- def dispatch_export(path, format)
524
- if format == 'json'
525
- export_json(path)
526
- elsif format == 'html'
527
- export_html(path)
528
- else
529
- export_markdown(path)
530
- end
531
- end
532
-
533
353
  # rubocop:disable Metrics/AbcSize
534
354
  def handle_tools
535
355
  lex_gems = Gem::Specification.select { |s| s.name.start_with?('lex-') }
@@ -549,53 +369,6 @@ module Legion
549
369
 
550
370
  # rubocop:enable Metrics/AbcSize
551
371
 
552
- def handle_dashboard
553
- if @app.respond_to?(:toggle_dashboard)
554
- @app.toggle_dashboard
555
- else
556
- @message_stream.add_message(role: :system, content: 'Dashboard not available.')
557
- end
558
- :handled
559
- end
560
-
561
- def handle_hotkeys
562
- if @app.respond_to?(:hotkeys)
563
- bindings = @app.hotkeys.list
564
- lines = bindings.map { |b| "#{b[:key].inspect} -> #{b[:description]}" }
565
- text = lines.empty? ? 'No hotkeys registered.' : lines.join("\n")
566
- @message_stream.add_message(role: :system, content: "Hotkeys:\n#{text}")
567
- else
568
- @message_stream.add_message(role: :system, content: 'Hotkeys not available.')
569
- end
570
- :handled
571
- end
572
-
573
- def handle_system(input)
574
- text = input.split(nil, 2)[1]
575
- if text
576
- if @llm_chat.respond_to?(:with_instructions)
577
- @llm_chat.with_instructions(text)
578
- @message_stream.add_message(role: :system, content: 'System prompt updated.')
579
- else
580
- @message_stream.add_message(role: :system, content: 'No active LLM session.')
581
- end
582
- else
583
- @message_stream.add_message(role: :system, content: 'Usage: /system <prompt text>')
584
- end
585
- :handled
586
- end
587
-
588
- def handle_delete(input)
589
- name = input.split(nil, 2)[1]
590
- unless name
591
- @message_stream.add_message(role: :system, content: 'Usage: /delete <session-name>')
592
- return :handled
593
- end
594
- @session_store.delete(name)
595
- @message_stream.add_message(role: :system, content: "Session '#{name}' deleted.")
596
- :handled
597
- end
598
-
599
372
  def handle_plan
600
373
  @plan_mode = !@plan_mode
601
374
  if @plan_mode
@@ -609,45 +382,12 @@ module Legion
609
382
  :handled
610
383
  end
611
384
 
612
- def handle_palette
613
- require_relative '../components/command_palette'
614
- palette = Components::CommandPalette.new(session_store: @session_store)
615
- selection = palette.select_with_prompt(output: @output)
616
- return :handled unless selection
617
-
618
- if selection.start_with?('/')
619
- handle_slash_command(selection)
620
- else
621
- dispatch_screen_by_name(selection)
622
- end
623
- :handled
624
- end
625
-
626
- def dispatch_screen_by_name(name)
627
- case name
628
- when 'dashboard' then handle_dashboard
629
- when 'extensions' then handle_extensions_screen
630
- when 'config' then handle_config_screen
385
+ def handle_session(input)
386
+ name = input.split(nil, 2)[1]
387
+ if name
388
+ @session_name = name
389
+ @status_bar.update(session: name)
631
390
  end
632
- end
633
-
634
- def handle_extensions_screen
635
- require_relative '../screens/extensions'
636
- screen = Screens::Extensions.new(@app, output: @output)
637
- @app.screen_manager.push(screen)
638
- :handled
639
- rescue LoadError
640
- @message_stream.add_message(role: :system, content: 'Extensions screen not available.')
641
- :handled
642
- end
643
-
644
- def handle_config_screen
645
- require_relative '../screens/config'
646
- screen = Screens::Config.new(@app, output: @output)
647
- @app.screen_manager.push(screen)
648
- :handled
649
- rescue LoadError
650
- @message_stream.add_message(role: :system, content: 'Config screen not available.')
651
391
  :handled
652
392
  end
653
393
 
@@ -669,434 +409,6 @@ module Legion
669
409
  :handled
670
410
  end
671
411
 
672
- def handle_search(input)
673
- query = input.split(nil, 2)[1]
674
- unless query
675
- @message_stream.add_message(role: :system, content: 'Usage: /search <text>')
676
- return :handled
677
- end
678
-
679
- results = search_messages(query)
680
- if results.empty?
681
- @message_stream.add_message(role: :system, content: "No messages matching '#{query}'.")
682
- else
683
- lines = results.map { |r| " [#{r[:role]}] #{truncate_text(r[:content], 80)}" }
684
- @message_stream.add_message(
685
- role: :system,
686
- content: "Found #{results.size} message(s) matching '#{query}':\n#{lines.join("\n")}"
687
- )
688
- end
689
- :handled
690
- end
691
-
692
- def handle_stats
693
- @message_stream.add_message(role: :system, content: build_stats_lines.join("\n"))
694
- :handled
695
- end
696
-
697
- def build_stats_lines
698
- msgs = @message_stream.messages
699
- counts = count_by_role(msgs)
700
- total_chars = msgs.sum { |m| m[:content].to_s.length }
701
- lines = stats_header_lines(msgs, counts, total_chars)
702
- lines << " Tool calls: #{counts[:tool]}" if counts[:tool].positive?
703
- lines
704
- end
705
-
706
- def count_by_role(msgs)
707
- %i[user assistant system tool].to_h { |role| [role, msgs.count { |m| m[:role] == role }] }
708
- end
709
-
710
- def stats_header_lines(msgs, counts, total_chars)
711
- [
712
- "Messages: #{msgs.size} total",
713
- " User: #{counts[:user]}, Assistant: #{counts[:assistant]}, System: #{counts[:system]}",
714
- "Characters: #{format_stat_number(total_chars)}",
715
- "Session: #{@session_name}",
716
- "Tokens: #{@token_tracker.summary}"
717
- ]
718
- end
719
-
720
- def format_stat_number(num)
721
- num.to_s.chars.reverse.each_slice(3).map(&:join).join(',').reverse
722
- end
723
-
724
- def handle_personality(input)
725
- name = input.split(nil, 2)[1]
726
- if name && PERSONALITIES.key?(name)
727
- apply_personality(name)
728
- elsif name
729
- available = PERSONALITIES.keys.join(', ')
730
- @message_stream.add_message(role: :system,
731
- content: "Unknown personality '#{name}'. Available: #{available}")
732
- else
733
- current = @personality || 'default'
734
- available = PERSONALITIES.keys.join(', ')
735
- @message_stream.add_message(role: :system, content: "Current: #{current}\nAvailable: #{available}")
736
- end
737
- :handled
738
- end
739
-
740
- def apply_personality(name)
741
- @personality = name
742
- if @llm_chat.respond_to?(:with_instructions)
743
- @llm_chat.with_instructions(PERSONALITIES[name])
744
- @message_stream.add_message(role: :system, content: "Personality switched to: #{name}")
745
- else
746
- @message_stream.add_message(role: :system, content: "Personality set to: #{name} (no active LLM)")
747
- end
748
- end
749
-
750
- # rubocop:disable Metrics/AbcSize
751
- def handle_compact(input)
752
- keep = (input.split(nil, 2)[1] || '5').to_i.clamp(1, 50)
753
- msgs = @message_stream.messages
754
- if msgs.size <= keep * 2
755
- @message_stream.add_message(role: :system, content: 'Conversation is already compact.')
756
- return :handled
757
- end
758
-
759
- system_msgs = msgs.select { |m| m[:role] == :system }
760
- recent = msgs.reject { |m| m[:role] == :system }.last(keep * 2)
761
- removed_count = msgs.size - system_msgs.size - recent.size
762
- @message_stream.messages.replace(system_msgs + recent)
763
- @message_stream.add_message(
764
- role: :system,
765
- content: "Compacted: removed #{removed_count} older messages, kept #{recent.size} recent."
766
- )
767
- :handled
768
- end
769
- # rubocop:enable Metrics/AbcSize
770
-
771
- def handle_copy(_input)
772
- last_assistant = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
773
- unless last_assistant
774
- @message_stream.add_message(role: :system, content: 'No assistant message to copy.')
775
- return :handled
776
- end
777
-
778
- content = last_assistant[:content].to_s
779
- copy_to_clipboard(content)
780
- @message_stream.add_message(
781
- role: :system,
782
- content: "Copied #{content.length} characters to clipboard."
783
- )
784
- :handled
785
- end
786
-
787
- def copy_to_clipboard(text)
788
- IO.popen('pbcopy', 'w') { |io| io.write(text) }
789
- rescue Errno::ENOENT
790
- begin
791
- IO.popen('xclip -selection clipboard', 'w') { |io| io.write(text) }
792
- rescue Errno::ENOENT
793
- nil
794
- end
795
- end
796
-
797
- def handle_diff(_input)
798
- if @loaded_message_count.nil?
799
- @message_stream.add_message(role: :system, content: 'No session was loaded. Nothing to diff against.')
800
- return :handled
801
- end
802
-
803
- new_count = @message_stream.messages.size - @loaded_message_count
804
- if new_count <= 0
805
- @message_stream.add_message(role: :system, content: 'No new messages since session was loaded.')
806
- else
807
- new_msgs = @message_stream.messages.last(new_count)
808
- lines = new_msgs.map { |m| " + [#{m[:role]}] #{truncate_text(m[:content].to_s, 60)}" }
809
- @message_stream.add_message(
810
- role: :system,
811
- content: "#{new_count} new message(s) since load:\n#{lines.join("\n")}"
812
- )
813
- end
814
- :handled
815
- end
816
-
817
- def search_messages(query)
818
- pattern = query.downcase
819
- @message_stream.messages.select do |msg|
820
- msg[:content].is_a?(::String) && msg[:content].downcase.include?(pattern)
821
- end
822
- end
823
-
824
- def truncate_text(text, max_length)
825
- return text if text.length <= max_length
826
-
827
- "#{text[0...max_length]}..."
828
- end
829
-
830
- def detect_provider
831
- cfg = safe_config
832
- provider = cfg[:provider].to_s.downcase
833
- return provider if Components::TokenTracker::PROVIDER_PRICING.key?(provider)
834
-
835
- 'claude'
836
- end
837
-
838
- def track_response_tokens(response)
839
- return unless response.respond_to?(:input_tokens)
840
-
841
- model_id = response.respond_to?(:model) ? response.model.to_s : nil
842
- @token_tracker.track(
843
- input_tokens: response.input_tokens.to_i,
844
- output_tokens: response.output_tokens.to_i,
845
- model: model_id
846
- )
847
- @status_bar.update(
848
- tokens: @token_tracker.total_input_tokens + @token_tracker.total_output_tokens,
849
- cost: @token_tracker.total_cost
850
- )
851
- end
852
-
853
- def export_markdown(path)
854
- lines = ["# Chat Export\n", "_Exported: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}_\n\n---\n"]
855
- @message_stream.messages.each do |msg|
856
- role_label = msg[:role].to_s.capitalize
857
- lines << "\n**#{role_label}**\n\n#{msg[:content]}\n"
858
- end
859
- File.write(path, lines.join)
860
- end
861
-
862
- def export_json(path)
863
- require 'json'
864
- data = {
865
- exported_at: Time.now.iso8601,
866
- token_summary: @token_tracker.summary,
867
- messages: @message_stream.messages.map { |m| { role: m[:role].to_s, content: m[:content] } }
868
- }
869
- File.write(path, ::JSON.pretty_generate(data))
870
- end
871
-
872
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
873
- def export_html(path)
874
- lines = [
875
- '<!DOCTYPE html><html><head>',
876
- '<meta charset="utf-8">',
877
- '<title>Chat Export</title>',
878
- '<style>',
879
- 'body { font-family: system-ui; max-width: 800px; margin: 0 auto; ' \
880
- 'padding: 20px; background: #1e1b2e; color: #d0cce6; }',
881
- '.msg { margin: 12px 0; padding: 8px 12px; border-radius: 8px; }',
882
- '.user { background: #2a2640; }',
883
- '.assistant { background: #1a1730; }',
884
- '.system { background: #25223a; color: #8b85a8; font-style: italic; }',
885
- '.role { font-weight: bold; color: #9d91e6; font-size: 0.85em; }',
886
- '</style></head><body>',
887
- '<h1>Chat Export</h1>',
888
- "<p>Exported: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}</p>"
889
- ]
890
- @message_stream.messages.each do |msg|
891
- role = msg[:role].to_s
892
- content = escape_html(msg[:content].to_s).gsub("\n", '<br>')
893
- lines << "<div class='msg #{role}'>"
894
- lines << "<span class='role'>#{role.capitalize}</span>"
895
- lines << "<p>#{content}</p>"
896
- lines << '</div>'
897
- end
898
- lines << '</body></html>'
899
- File.write(path, lines.join("\n"))
900
- end
901
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
902
-
903
- def escape_html(text)
904
- text.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
905
- end
906
-
907
- def handle_undo
908
- msgs = @message_stream.messages
909
- last_user_idx = msgs.rindex { |m| m[:role] == :user }
910
- unless last_user_idx
911
- @message_stream.add_message(role: :system, content: 'Nothing to undo.')
912
- return :handled
913
- end
914
-
915
- msgs.slice!(last_user_idx..)
916
- :handled
917
- end
918
-
919
- def handle_history
920
- entries = @input_bar.history
921
- if entries.empty?
922
- @message_stream.add_message(role: :system, content: 'No input history.')
923
- else
924
- recent = entries.last(20)
925
- lines = recent.each_with_index.map { |entry, i| " #{i + 1}. #{entry}" }
926
- @message_stream.add_message(role: :system,
927
- content: "Input history (last #{recent.size}):\n#{lines.join("\n")}")
928
- end
929
- :handled
930
- end
931
-
932
- def handle_pin(input)
933
- idx_str = input.split(nil, 2)[1]
934
- msg = if idx_str
935
- @message_stream.messages[idx_str.to_i]
936
- else
937
- @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
938
- end
939
- unless msg
940
- @message_stream.add_message(role: :system, content: 'No message to pin.')
941
- return :handled
942
- end
943
-
944
- @pinned_messages << msg
945
- preview = truncate_text(msg[:content].to_s, 60)
946
- @message_stream.add_message(role: :system, content: "Pinned: #{preview}")
947
- :handled
948
- end
949
-
950
- def handle_pins
951
- if @pinned_messages.empty?
952
- @message_stream.add_message(role: :system, content: 'No pinned messages.')
953
- else
954
- lines = @pinned_messages.each_with_index.map do |msg, i|
955
- " #{i + 1}. [#{msg[:role]}] #{truncate_text(msg[:content].to_s, 70)}"
956
- end
957
- @message_stream.add_message(role: :system,
958
- content: "Pinned messages (#{@pinned_messages.size}):\n#{lines.join("\n")}")
959
- end
960
- :handled
961
- end
962
-
963
- def handle_rename(input)
964
- name = input.split(nil, 2)[1]
965
- unless name
966
- @message_stream.add_message(role: :system, content: 'Usage: /rename <new-name>')
967
- return :handled
968
- end
969
-
970
- old_name = @session_name
971
- @session_store.delete(old_name) if old_name != 'default'
972
- @session_name = name
973
- @status_bar.update(session: name)
974
- @session_store.save(name, messages: @message_stream.messages)
975
- @message_stream.add_message(role: :system, content: "Session renamed to '#{name}'.")
976
- :handled
977
- end
978
-
979
- # rubocop:disable Metrics/AbcSize
980
- def handle_context
981
- cfg = safe_config
982
- model_info = @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : (cfg[:provider] || 'none')
983
- sys_prompt = if @llm_chat.respond_to?(:instructions) && @llm_chat.instructions
984
- truncate_text(@llm_chat.instructions.to_s, 80)
985
- else
986
- 'default'
987
- end
988
- lines = [
989
- 'Session Context:',
990
- " Model/Provider : #{model_info}",
991
- " Personality : #{@personality || 'default'}",
992
- " Plan mode : #{@plan_mode ? 'on' : 'off'}",
993
- " System prompt : #{sys_prompt}",
994
- " Session : #{@session_name}",
995
- " Messages : #{@message_stream.messages.size}",
996
- " Pinned : #{@pinned_messages.size}",
997
- " Tokens : #{@token_tracker.summary}"
998
- ]
999
- @message_stream.add_message(role: :system, content: lines.join("\n"))
1000
- :handled
1001
- end
1002
- # rubocop:enable Metrics/AbcSize
1003
-
1004
- def handle_alias(input)
1005
- parts = input.split(nil, 3)
1006
- if parts.size < 2
1007
- if @aliases.empty?
1008
- @message_stream.add_message(role: :system, content: 'No aliases defined.')
1009
- else
1010
- lines = @aliases.map { |k, v| " #{k} => #{v}" }
1011
- @message_stream.add_message(role: :system, content: "Aliases:\n#{lines.join("\n")}")
1012
- end
1013
- return :handled
1014
- end
1015
-
1016
- shortname = parts[1]
1017
- expansion = parts[2]
1018
- unless expansion
1019
- @message_stream.add_message(role: :system, content: 'Usage: /alias <shortname> <command and args>')
1020
- return :handled
1021
- end
1022
-
1023
- alias_key = shortname.start_with?('/') ? shortname : "/#{shortname}"
1024
- @aliases[alias_key] = expansion
1025
- @message_stream.add_message(role: :system, content: "Alias created: #{alias_key} => #{expansion}")
1026
- :handled
1027
- end
1028
-
1029
- def handle_snippet(input)
1030
- parts = input.split(nil, 3)
1031
- subcommand = parts[1]
1032
- name = parts[2]
1033
-
1034
- case subcommand
1035
- when 'save'
1036
- snippet_save(name)
1037
- when 'load'
1038
- snippet_load(name)
1039
- when 'list'
1040
- snippet_list
1041
- when 'delete'
1042
- snippet_delete(name)
1043
- else
1044
- @message_stream.add_message(
1045
- role: :system,
1046
- content: 'Usage: /snippet save|load|list|delete <name>'
1047
- )
1048
- end
1049
- :handled
1050
- end
1051
-
1052
- def handle_debug
1053
- @debug_mode = !@debug_mode
1054
- if @debug_mode
1055
- @status_bar.update(debug_mode: true)
1056
- @message_stream.add_message(role: :system, content: 'Debug mode ON -- internal state shown below.')
1057
- else
1058
- @status_bar.update(debug_mode: false)
1059
- @message_stream.add_message(role: :system, content: 'Debug mode OFF.')
1060
- end
1061
- :handled
1062
- end
1063
-
1064
- def handle_uptime
1065
- elapsed = Time.now - @session_start
1066
- hours = (elapsed / 3600).to_i
1067
- minutes = ((elapsed % 3600) / 60).to_i
1068
- seconds = (elapsed % 60).to_i
1069
- @message_stream.add_message(role: :system, content: "Session uptime: #{hours}h #{minutes}m #{seconds}s")
1070
- :handled
1071
- end
1072
-
1073
- # rubocop:disable Metrics/AbcSize
1074
- def handle_bookmark
1075
- require 'fileutils'
1076
- if @pinned_messages.empty?
1077
- @message_stream.add_message(role: :system, content: 'No pinned messages to export.')
1078
- return :handled
1079
- end
1080
-
1081
- exports_dir = File.expand_path('~/.legionio/exports')
1082
- FileUtils.mkdir_p(exports_dir)
1083
- timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
1084
- path = File.join(exports_dir, "bookmarks-#{timestamp}.md")
1085
- lines = ["# Pinned Messages\n", "_Exported: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}_\n\n---\n"]
1086
- @pinned_messages.each_with_index do |msg, i|
1087
- role_label = msg[:role].to_s.capitalize
1088
- lines << "\n## Bookmark #{i + 1} (#{role_label})\n\n#{msg[:content]}\n"
1089
- end
1090
- File.write(path, lines.join)
1091
- @message_stream.add_message(role: :system, content: "Bookmarks exported to: #{path}")
1092
- :handled
1093
- rescue StandardError => e
1094
- @message_stream.add_message(role: :system, content: "Bookmark export failed: #{e.message}")
1095
- :handled
1096
- end
1097
-
1098
- # rubocop:enable Metrics/AbcSize
1099
-
1100
412
  def debug_segment
1101
413
  return nil unless @debug_mode
1102
414
 
@@ -1109,91 +421,6 @@ module Legion
1109
421
  "pinned:#{@pinned_messages.size}"
1110
422
  end
1111
423
 
1112
- def snippet_dir
1113
- File.expand_path('~/.legionio/snippets')
1114
- end
1115
-
1116
- # rubocop:disable Metrics/AbcSize
1117
- def snippet_save(name)
1118
- unless name
1119
- @message_stream.add_message(role: :system, content: 'Usage: /snippet save <name>')
1120
- return
1121
- end
1122
-
1123
- last_assistant = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
1124
- unless last_assistant
1125
- @message_stream.add_message(role: :system, content: 'No assistant message to save as snippet.')
1126
- return
1127
- end
1128
-
1129
- require 'fileutils'
1130
- FileUtils.mkdir_p(snippet_dir)
1131
- path = File.join(snippet_dir, "#{name}.txt")
1132
- File.write(path, last_assistant[:content].to_s)
1133
- @snippets[name] = last_assistant[:content].to_s
1134
- @message_stream.add_message(role: :system, content: "Snippet '#{name}' saved.")
1135
- end
1136
- # rubocop:enable Metrics/AbcSize
1137
-
1138
- def snippet_load(name)
1139
- unless name
1140
- @message_stream.add_message(role: :system, content: 'Usage: /snippet load <name>')
1141
- return
1142
- end
1143
-
1144
- content = @snippets[name]
1145
- if content.nil?
1146
- path = File.join(snippet_dir, "#{name}.txt")
1147
- content = File.read(path) if File.exist?(path)
1148
- end
1149
-
1150
- unless content
1151
- @message_stream.add_message(role: :system, content: "Snippet '#{name}' not found.")
1152
- return
1153
- end
1154
-
1155
- @snippets[name] = content
1156
- @message_stream.add_message(role: :user, content: content)
1157
- @message_stream.add_message(role: :system, content: "Snippet '#{name}' inserted.")
1158
- end
1159
-
1160
- # rubocop:disable Metrics/AbcSize
1161
- def snippet_list
1162
- disk_snippets = Dir.glob(File.join(snippet_dir, '*.txt')).map { |f| File.basename(f, '.txt') }
1163
- all_names = (@snippets.keys + disk_snippets).uniq.sort
1164
-
1165
- if all_names.empty?
1166
- @message_stream.add_message(role: :system, content: 'No snippets saved.')
1167
- return
1168
- end
1169
-
1170
- lines = all_names.map do |sname|
1171
- content = @snippets[sname] || begin
1172
- path = File.join(snippet_dir, "#{sname}.txt")
1173
- File.exist?(path) ? File.read(path) : ''
1174
- end
1175
- " #{sname}: #{truncate_text(content.to_s, 60)}"
1176
- end
1177
- @message_stream.add_message(role: :system, content: "Snippets (#{all_names.size}):\n#{lines.join("\n")}")
1178
- end
1179
- # rubocop:enable Metrics/AbcSize
1180
-
1181
- def snippet_delete(name)
1182
- unless name
1183
- @message_stream.add_message(role: :system, content: 'Usage: /snippet delete <name>')
1184
- return
1185
- end
1186
-
1187
- @snippets.delete(name)
1188
- path = File.join(snippet_dir, "#{name}.txt")
1189
- if File.exist?(path)
1190
- File.delete(path)
1191
- @message_stream.add_message(role: :system, content: "Snippet '#{name}' deleted.")
1192
- else
1193
- @message_stream.add_message(role: :system, content: "Snippet '#{name}' not found.")
1194
- end
1195
- end
1196
-
1197
424
  def build_default_input_bar
1198
425
  cfg = safe_config
1199
426
  name = cfg[:name] || 'User'
@@ -1213,6 +440,29 @@ module Legion
1213
440
  rescue StandardError
1214
441
  24
1215
442
  end
443
+
444
+ def detect_provider
445
+ cfg = safe_config
446
+ provider = cfg[:provider].to_s.downcase
447
+ return provider if Components::TokenTracker::PROVIDER_PRICING.key?(provider)
448
+
449
+ 'claude'
450
+ end
451
+
452
+ def track_response_tokens(response)
453
+ return unless response.respond_to?(:input_tokens)
454
+
455
+ model_id = response.respond_to?(:model) ? response.model.to_s : nil
456
+ @token_tracker.track(
457
+ input_tokens: response.input_tokens.to_i,
458
+ output_tokens: response.output_tokens.to_i,
459
+ model: model_id
460
+ )
461
+ @status_bar.update(
462
+ tokens: @token_tracker.total_input_tokens + @token_tracker.total_output_tokens,
463
+ cost: @token_tracker.total_cost
464
+ )
465
+ end
1216
466
  end
1217
467
  # rubocop:enable Metrics/ClassLength
1218
468
  end