legion-tty 0.4.23 → 0.4.25

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f33a9d4d3640c42054bc91b873ec04898386dc635d5b3097e037c7695a3a9594
4
- data.tar.gz: 469499bb265ededb47d2c1a03778174b8cae152a163808ba5a606b1aaa3b94ee
3
+ metadata.gz: c2baad187737411e10cb975111c2e766cd5b1b1c20bc27b7731cc990672597a0
4
+ data.tar.gz: e2d8eaabf4459beba0805f830025321c11fa70ea572aba05640b005e20dbd12a
5
5
  SHA512:
6
- metadata.gz: 79090a1ed32486fa0e15f59312d34ee601c8cc92e8d596ccffda4cc7951cedd3d8461801b90669c1a34d22b32caa6c794836d7dc00633636d64c940dfc757633
7
- data.tar.gz: 3ccb8fde2f3e285cbed4f06ddc46132d9f5fd45bad3c5cc5a80d22bca704c2a8e1e90bf799e8e7095228fe1c1330318156792392a67ddcaba4b3eea9f439d094
6
+ metadata.gz: 53b05dc182ec33a56c75c58a3936c9753b018c8a78ac3ae83f2fac588bafdf5ecb09026a9f70120b86d7685ce75d484bd993ef83efabf84ee0f63c4d81daefb8
7
+ data.tar.gz: 6f6849814bba37b3ebee42a88be6a927efaa913437d1c050586902aa4850cbf334df571e310818b6ddc711ff5d0828ef89e12cb8e249d9b704248e73898e200c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.25] - 2026-03-19
4
+
5
+ ### Added
6
+ - `/ask <question>` command: quick concise Q&A mode (instructs LLM to answer in one paragraph)
7
+ - `/define <term>` command: ask LLM for a concise definition
8
+ - `/status` command: comprehensive view of all 18 toggleable modes and settings
9
+ - `/prefs [key] [value]` command: persistent user preferences in `~/.legionio/prefs.json`
10
+ - `/about` command: show legion-tty name, version, author, license, GitHub URL
11
+ - `/commands [pattern]` command: list all slash commands with optional pattern filter and count
12
+
13
+ ### Changed
14
+ - Total slash commands: 103 (milestone)
15
+
16
+ ## [0.4.24] - 2026-03-19
17
+
18
+ ### Added
19
+ - `/mark <label>` command: insert named markers/bookmarks in conversation, list all markers
20
+ - `/freq` command: word frequency analysis with top 20 words (excludes stop words)
21
+ - `/draft <text>` command: save text to draft buffer, show/clear/send draft
22
+ - `/revise <text>` command: replace content of last user message
23
+ - `/color [on|off]` command: toggle colorized output (strip ANSI codes when off)
24
+ - `/timestamps [on|off]` command: toggle timestamp display on messages
25
+ - `/top` command: scroll to top of message history
26
+ - `/bottom` command: scroll to bottom of message history
27
+ - `/head [N]` command: peek at first N messages (default 5)
28
+ - `/tail [N]` command: peek at last N messages (default 5)
29
+
3
30
  ## [0.4.23] - 2026-03-19
4
31
 
5
32
  ### Added
@@ -9,7 +9,8 @@ module Legion
9
9
  # rubocop:disable Metrics/ClassLength
10
10
  class MessageStream
11
11
  attr_reader :messages, :scroll_offset
12
- attr_accessor :mute_system, :silent_mode, :highlights, :filter, :truncate_limit, :wrap_width, :show_numbers
12
+ attr_accessor :mute_system, :silent_mode, :highlights, :filter, :truncate_limit, :wrap_width, :show_numbers,
13
+ :colorize, :show_timestamps
13
14
 
14
15
  HIGHLIGHT_COLOR = "\e[1;33m"
15
16
  HIGHLIGHT_RESET = "\e[0m"
@@ -22,6 +23,8 @@ module Legion
22
23
  @highlights = []
23
24
  @wrap_width = nil
24
25
  @show_numbers = false
26
+ @colorize = true
27
+ @show_timestamps = true
25
28
  end
26
29
 
27
30
  def add_message(role:, content:)
@@ -70,6 +73,7 @@ module Legion
70
73
  start_idx = [total - height - @scroll_offset, 0].max
71
74
  start_idx = [start_idx, total].min
72
75
  result = all_lines[start_idx, height] || []
76
+ result = result.map { |l| strip_ansi(l) } unless @colorize
73
77
  @last_visible_count = result.size
74
78
  result
75
79
  end
@@ -128,15 +132,18 @@ module Legion
128
132
  end
129
133
 
130
134
  def user_lines(msg, _width)
131
- ts = format_timestamp(msg[:timestamp])
132
- header = "#{Theme.c(:accent, 'You')} #{Theme.c(:muted, ts)}"
133
135
  content = apply_highlights(msg[:content].to_s)
134
- lines = ['', "#{header}: #{content}"]
136
+ lines = ['', "#{user_header(msg[:timestamp])}: #{content}"]
135
137
  lines << reaction_line(msg) if msg[:reactions]&.any?
136
138
  lines.concat(annotation_lines(msg)) if msg[:annotations]&.any?
137
139
  lines
138
140
  end
139
141
 
142
+ def user_header(timestamp)
143
+ ts = @show_timestamps ? format_timestamp(timestamp) : ''
144
+ ts.empty? ? Theme.c(:accent, 'You') : "#{Theme.c(:accent, 'You')} #{Theme.c(:muted, ts)}"
145
+ end
146
+
140
147
  def format_timestamp(time)
141
148
  return '' unless time
142
149
 
@@ -209,6 +216,10 @@ module Legion
209
216
  panel.instance_variable_set(:@result, result) if result
210
217
  panel.instance_variable_set(:@error, error) if error
211
218
  end
219
+
220
+ def strip_ansi(text)
221
+ text.gsub(/\e\[[0-9;]*m/, '')
222
+ end
212
223
  end
213
224
  # rubocop:enable Metrics/ClassLength
214
225
  end
@@ -560,6 +560,101 @@ module Legion
560
560
  @message_stream.add_message(role: :system, content: "Truncated to last #{n} messages.")
561
561
  :handled
562
562
  end
563
+
564
+ def handle_mark(input)
565
+ label = input.split(nil, 2)[1]
566
+ return list_markers if label.nil?
567
+
568
+ msg = { role: :system, content: "--- #{label} ---", marker: label }
569
+ @message_stream.messages << msg
570
+ @status_bar.update(message_count: @message_stream.messages.size)
571
+ :handled
572
+ end
573
+
574
+ def list_markers
575
+ markers = @message_stream.messages.each_with_index.select { |m, _| m[:marker] }
576
+ if markers.empty?
577
+ @message_stream.add_message(role: :system, content: 'No markers set.')
578
+ else
579
+ lines = markers.map { |m, i| " [#{i}] #{m[:content]}" }
580
+ @message_stream.add_message(role: :system, content: "Markers:\n#{lines.join("\n")}")
581
+ end
582
+ :handled
583
+ end
584
+
585
+ def handle_head(input)
586
+ n = (input.split(nil, 2)[1] || '5').to_i.clamp(1, 500)
587
+ msgs = @message_stream.messages.first(n)
588
+ if msgs.empty?
589
+ @message_stream.add_message(role: :system, content: 'No messages.')
590
+ return :handled
591
+ end
592
+
593
+ lines = msgs.map { |m| " [#{m[:role]}] #{truncate_text(m[:content].to_s, 80)}" }
594
+ @message_stream.add_message(role: :system, content: "First #{msgs.size} message(s):\n#{lines.join("\n")}")
595
+ :handled
596
+ end
597
+
598
+ def handle_tail(input)
599
+ n = (input.split(nil, 2)[1] || '5').to_i.clamp(1, 500)
600
+ msgs = @message_stream.messages.last(n)
601
+ if msgs.empty?
602
+ @message_stream.add_message(role: :system, content: 'No messages.')
603
+ return :handled
604
+ end
605
+
606
+ lines = msgs.map { |m| " [#{m[:role]}] #{truncate_text(m[:content].to_s, 80)}" }
607
+ @message_stream.add_message(role: :system, content: "Last #{msgs.size} message(s):\n#{lines.join("\n")}")
608
+ :handled
609
+ end
610
+
611
+ def handle_draft(input)
612
+ arg = input.split(nil, 2)[1]
613
+ case arg
614
+ when nil
615
+ content = @draft ? "Draft: #{@draft}" : 'No draft saved.'
616
+ @message_stream.add_message(role: :system, content: content)
617
+ when 'clear'
618
+ @draft = nil
619
+ @message_stream.add_message(role: :system, content: 'Draft cleared.')
620
+ when 'send'
621
+ return draft_send
622
+ else
623
+ @draft = arg
624
+ @message_stream.add_message(role: :system, content: "Draft saved: #{@draft}")
625
+ end
626
+ :handled
627
+ end
628
+
629
+ def draft_send
630
+ unless @draft
631
+ @message_stream.add_message(role: :system, content: 'No draft to send.')
632
+ return :handled
633
+ end
634
+
635
+ text = @draft
636
+ @draft = nil
637
+ handle_user_message(text)
638
+ :handled
639
+ end
640
+
641
+ def handle_revise(input)
642
+ new_content = input.split(nil, 2)[1]
643
+ unless new_content
644
+ @message_stream.add_message(role: :system, content: 'Usage: /revise <new content>')
645
+ return :handled
646
+ end
647
+
648
+ msg = @message_stream.messages.reverse.find { |m| m[:role] == :user }
649
+ unless msg
650
+ @message_stream.add_message(role: :system, content: 'No user message to revise.')
651
+ return :handled
652
+ end
653
+
654
+ msg[:content] = new_content
655
+ @message_stream.add_message(role: :system, content: "Revised: #{new_content}")
656
+ :handled
657
+ end
563
658
  end
564
659
  end
565
660
  end
@@ -154,6 +154,32 @@ module Legion
154
154
  end
155
155
  :handled
156
156
  end
157
+
158
+ def handle_ask(input)
159
+ question = input.split(nil, 2)[1]&.strip
160
+ unless question && !question.empty?
161
+ @message_stream.add_message(role: :system, content: 'Usage: /ask <question>')
162
+ return :handled
163
+ end
164
+
165
+ @message_stream.add_message(role: :user, content: question)
166
+ @message_stream.add_message(role: :assistant, content: '')
167
+ send_to_llm("Answer the following question concisely in one paragraph: #{question}")
168
+ :handled
169
+ end
170
+
171
+ def handle_define(input)
172
+ term = input.split(nil, 2)[1]&.strip
173
+ unless term && !term.empty?
174
+ @message_stream.add_message(role: :system, content: 'Usage: /define <term>')
175
+ return :handled
176
+ end
177
+
178
+ @message_stream.add_message(role: :user, content: term)
179
+ @message_stream.add_message(role: :assistant, content: '')
180
+ send_to_llm("Define the following term concisely: #{term}")
181
+ :handled
182
+ end
157
183
  end
158
184
  end
159
185
  end
@@ -37,6 +37,12 @@ module Legion
37
37
 
38
38
  CALC_SAFE_PATTERN = %r{\A[\d\s+\-*/.()%]*\z}
39
39
  CALC_MATH_PATTERN = %r{\A[\d\s+\-*/.()%]*(Math\.\w+\([\d\s+\-*/.()%,]*\)[\d\s+\-*/.()%]*)*\z}
40
+ FREQ_STOP_WORDS = %w[
41
+ the a an is are was were be been have has had do does did will would could should
42
+ may might can shall to of in for on with at by from it this that i you we they
43
+ he she my your our their and or but not no if then so as
44
+ ].freeze
45
+ FREQ_ROW_FMT = ' %<rank>2d. %-<word>20s %<count>5d %<pct>5.1f%%'
40
46
 
41
47
  private
42
48
 
@@ -637,6 +643,215 @@ module Legion
637
643
  end
638
644
  :handled
639
645
  end
646
+
647
+ def handle_color(input)
648
+ arg = input.split(nil, 2)[1]&.strip
649
+ new_state = case arg
650
+ when 'on' then true
651
+ when 'off' then false
652
+ else !@message_stream.colorize
653
+ end
654
+ @message_stream.colorize = new_state
655
+ state_label = new_state ? 'ON' : 'OFF'
656
+ @message_stream.add_message(role: :system, content: "Color output #{state_label}.")
657
+ :handled
658
+ end
659
+
660
+ def handle_timestamps(input)
661
+ arg = input.split(nil, 2)[1]&.strip
662
+ new_state = case arg
663
+ when 'on' then true
664
+ when 'off' then false
665
+ else !@message_stream.show_timestamps
666
+ end
667
+ @message_stream.show_timestamps = new_state
668
+ state_label = new_state ? 'ON' : 'OFF'
669
+ @message_stream.add_message(role: :system, content: "Timestamps #{state_label}.")
670
+ :handled
671
+ end
672
+
673
+ def handle_top
674
+ @message_stream.scroll_up(@message_stream.messages.size * 5)
675
+ :handled
676
+ end
677
+
678
+ def handle_bottom
679
+ @message_stream.scroll_down(@message_stream.scroll_offset)
680
+ :handled
681
+ end
682
+
683
+ def handle_about
684
+ lines = [
685
+ 'legion-tty',
686
+ 'Description : Rich terminal UI for the LegionIO async cognition engine',
687
+ "Version : #{Legion::TTY::VERSION}",
688
+ 'Author : Matthew Iverson (@Esity)',
689
+ 'License : Apache-2.0',
690
+ 'GitHub : https://github.com/LegionIO/legion-tty'
691
+ ]
692
+ @message_stream.add_message(role: :system, content: lines.join("\n"))
693
+ :handled
694
+ end
695
+
696
+ def handle_commands(input)
697
+ pattern = input.split(nil, 2)[1]&.strip&.downcase
698
+ cmds = filter_commands(pattern)
699
+ header = commands_header(pattern, cmds)
700
+ rows = format_command_columns(cmds)
701
+ @message_stream.add_message(role: :system, content: "#{header}\n#{rows.join("\n")}")
702
+ :handled
703
+ end
704
+
705
+ def filter_commands(pattern)
706
+ cmds = SLASH_COMMANDS.sort
707
+ return cmds unless pattern && !pattern.empty?
708
+
709
+ cmds.select { |c| c.include?(pattern) }
710
+ end
711
+
712
+ def commands_header(pattern, cmds)
713
+ if pattern && !pattern.empty?
714
+ "Commands matching '#{pattern}' (#{cmds.size}):"
715
+ else
716
+ "All commands (#{cmds.size}):"
717
+ end
718
+ end
719
+
720
+ def format_command_columns(cmds)
721
+ col_width = cmds.map(&:length).max.to_i + 2
722
+ cols = [terminal_width / [col_width, 1].max, 1].max
723
+ cmds.each_slice(cols).map { |row| row.map { |c| c.ljust(col_width) }.join.rstrip }
724
+ end
725
+
726
+ def handle_freq
727
+ words = collect_freq_words
728
+ if words.empty?
729
+ @message_stream.add_message(role: :system, content: 'No words to analyse.')
730
+ return :handled
731
+ end
732
+
733
+ top = words.tally.sort_by { |_, c| -c }.first(20)
734
+ header = ' # word count %'
735
+ lines = format_freq_lines(top, words.size)
736
+ @message_stream.add_message(role: :system,
737
+ content: "Word frequency (top #{top.size}):\n#{header}\n#{lines.join("\n")}")
738
+ :handled
739
+ end
740
+
741
+ def collect_freq_words
742
+ @message_stream.messages
743
+ .flat_map { |m| m[:content].to_s.downcase.scan(/[a-z']+/) }
744
+ .reject { |w| FREQ_STOP_WORDS.include?(w) || w.length < 2 }
745
+ end
746
+
747
+ def format_freq_lines(top, total)
748
+ top.map.with_index(1) do |(word, count), rank|
749
+ pct = (count.to_f / total * 100).round(1)
750
+ format(FREQ_ROW_FMT, rank: rank, word: word, count: count, pct: pct)
751
+ end
752
+ end
753
+
754
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
755
+ def handle_status
756
+ autosave_val = @autosave_enabled ? "on (every #{@autosave_interval}s)" : 'off'
757
+ filter_val = @message_stream.filter ? @message_stream.filter[:type].to_s : 'none'
758
+ wrap_val = @message_stream.wrap_width ? @message_stream.wrap_width.to_s : 'off'
759
+ truncate_val = @message_stream.truncate_limit ? @message_stream.truncate_limit.to_s : 'off'
760
+ tee_val = @tee_path || 'off'
761
+ lines = [
762
+ 'Mode Status:',
763
+ " Plan mode : #{@plan_mode ? 'on' : 'off'}",
764
+ " Focus mode : #{@focus_mode ? 'on' : 'off'}",
765
+ " Debug mode : #{@debug_mode ? 'on' : 'off'}",
766
+ " Silent mode: #{@silent_mode ? 'on' : 'off'}",
767
+ " Mute system: #{@muted_system ? 'on' : 'off'}",
768
+ " Multi-line : #{@multiline_mode ? 'on' : 'off'}",
769
+ " Speak mode : #{defined?(@speak_mode) && @speak_mode ? 'on' : 'off'}",
770
+ " Autosave : #{autosave_val}",
771
+ " Color : #{@message_stream.colorize ? 'on' : 'off'}",
772
+ " Timestamps : #{@message_stream.show_timestamps ? 'on' : 'off'}",
773
+ " Numbers : #{@message_stream.show_numbers ? 'on' : 'off'}",
774
+ " Wrap : #{wrap_val}",
775
+ " Truncate : #{truncate_val}",
776
+ " Filter : #{filter_val}",
777
+ " Tee : #{tee_val}",
778
+ " Theme : #{Theme.current_theme}",
779
+ " Personality: #{@personality || 'default'}"
780
+ ]
781
+ @message_stream.add_message(role: :system, content: lines.join("\n"))
782
+ :handled
783
+ end
784
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
785
+
786
+ def handle_prefs(input)
787
+ parts = input.split(nil, 3)
788
+ key = parts[1]
789
+ value = parts[2]
790
+ if key.nil?
791
+ show_all_prefs
792
+ elsif value.nil?
793
+ show_one_pref(key)
794
+ else
795
+ set_pref(key, value)
796
+ end
797
+ :handled
798
+ end
799
+
800
+ def prefs_path
801
+ File.expand_path('~/.legionio/prefs.json')
802
+ end
803
+
804
+ def load_prefs
805
+ return {} unless File.exist?(prefs_path)
806
+
807
+ require 'json'
808
+ ::JSON.parse(File.read(prefs_path))
809
+ rescue ::JSON::ParserError
810
+ {}
811
+ end
812
+
813
+ def save_prefs(prefs)
814
+ require 'fileutils'
815
+ require 'json'
816
+ FileUtils.mkdir_p(File.dirname(prefs_path))
817
+ File.write(prefs_path, ::JSON.pretty_generate(prefs))
818
+ end
819
+
820
+ def show_all_prefs
821
+ prefs = load_prefs
822
+ if prefs.empty?
823
+ @message_stream.add_message(role: :system, content: 'No preferences saved.')
824
+ else
825
+ lines = prefs.map { |k, v| " #{k}: #{v}" }
826
+ @message_stream.add_message(role: :system, content: "Preferences:\n#{lines.join("\n")}")
827
+ end
828
+ end
829
+
830
+ def show_one_pref(key)
831
+ val = load_prefs[key]
832
+ if val.nil?
833
+ @message_stream.add_message(role: :system, content: "No preference set for '#{key}'.")
834
+ else
835
+ @message_stream.add_message(role: :system, content: "#{key}: #{val}")
836
+ end
837
+ end
838
+
839
+ def set_pref(key, value)
840
+ prefs = load_prefs
841
+ prefs[key] = value
842
+ save_prefs(prefs)
843
+ apply_pref(key, value)
844
+ @message_stream.add_message(role: :system, content: "Preference set: #{key} = #{value}")
845
+ end
846
+
847
+ def apply_pref(key, value)
848
+ case key
849
+ when 'theme' then handle_theme("/theme #{value}")
850
+ when 'personality' then handle_personality("/personality #{value}")
851
+ when 'color' then handle_color("/color #{value}")
852
+ when 'timestamps' then handle_timestamps("/timestamps #{value}")
853
+ end
854
+ end
640
855
  end
641
856
  end
642
857
  end
@@ -41,7 +41,14 @@ module Legion
41
41
  /echo /env
42
42
  /ls /pwd
43
43
  /wrap /number
44
- /speak /silent].freeze
44
+ /speak /silent
45
+ /color /timestamps
46
+ /top /bottom /head /tail
47
+ /draft /revise
48
+ /mark /freq
49
+ /about /commands
50
+ /ask /define
51
+ /status /prefs].freeze
45
52
 
46
53
  PERSONALITIES = {
47
54
  'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
@@ -85,6 +92,7 @@ module Legion
85
92
  @multiline_mode = false
86
93
  @speak_mode = false
87
94
  @silent_mode = false
95
+ @draft = nil
88
96
  end
89
97
 
90
98
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -477,6 +485,22 @@ module Legion
477
485
  when '/number' then handle_number(input)
478
486
  when '/speak' then handle_speak(input)
479
487
  when '/silent' then handle_silent
488
+ when '/color' then handle_color(input)
489
+ when '/timestamps' then handle_timestamps(input)
490
+ when '/top' then handle_top
491
+ when '/bottom' then handle_bottom
492
+ when '/head' then handle_head(input)
493
+ when '/tail' then handle_tail(input)
494
+ when '/draft' then handle_draft(input)
495
+ when '/revise' then handle_revise(input)
496
+ when '/mark' then handle_mark(input)
497
+ when '/freq' then handle_freq
498
+ when '/about' then handle_about
499
+ when '/commands' then handle_commands(input)
500
+ when '/ask' then handle_ask(input)
501
+ when '/define' then handle_define(input)
502
+ when '/status' then handle_status
503
+ when '/prefs' then handle_prefs(input)
480
504
  else :handled
481
505
  end
482
506
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.23'
5
+ VERSION = '0.4.25'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-tty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.23
4
+ version: 0.4.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity