legion-tty 0.4.23 → 0.4.24

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: ab1a18bdbec9078051e19b84ede94cdaacb600ed12006674dc5696f711230349
4
+ data.tar.gz: 6735876baede0f4fd45637f6a5cb0cef4284f7117298fa96cb97c8ca667a23d4
5
5
  SHA512:
6
- metadata.gz: 79090a1ed32486fa0e15f59312d34ee601c8cc92e8d596ccffda4cc7951cedd3d8461801b90669c1a34d22b32caa6c794836d7dc00633636d64c940dfc757633
7
- data.tar.gz: 3ccb8fde2f3e285cbed4f06ddc46132d9f5fd45bad3c5cc5a80d22bca704c2a8e1e90bf799e8e7095228fe1c1330318156792392a67ddcaba4b3eea9f439d094
6
+ metadata.gz: '01294f74d7805fb5aad66aa1538ffe38cb1f8b407aaaa662d90cacaf7f8c0b4e787257636007f8a74e88e1e13b76a5432da4a2ff47aad0dd5b6f81852116ddfb'
7
+ data.tar.gz: 789298565c5285d7ef1787fd7dfbf8034b181f57f3cab243cd4b424712b0ec7251836e8c8f5ff3b79faf4b0d98c5416e919e8cf80c42df09adbe443d5b77905f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.24] - 2026-03-19
4
+
5
+ ### Added
6
+ - `/mark <label>` command: insert named markers/bookmarks in conversation, list all markers
7
+ - `/freq` command: word frequency analysis with top 20 words (excludes stop words)
8
+ - `/draft <text>` command: save text to draft buffer, show/clear/send draft
9
+ - `/revise <text>` command: replace content of last user message
10
+ - `/color [on|off]` command: toggle colorized output (strip ANSI codes when off)
11
+ - `/timestamps [on|off]` command: toggle timestamp display on messages
12
+ - `/top` command: scroll to top of message history
13
+ - `/bottom` command: scroll to bottom of message history
14
+ - `/head [N]` command: peek at first N messages (default 5)
15
+ - `/tail [N]` command: peek at last N messages (default 5)
16
+
3
17
  ## [0.4.23] - 2026-03-19
4
18
 
5
19
  ### 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
@@ -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,70 @@ 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_freq
684
+ words = collect_freq_words
685
+ if words.empty?
686
+ @message_stream.add_message(role: :system, content: 'No words to analyse.')
687
+ return :handled
688
+ end
689
+
690
+ top = words.tally.sort_by { |_, c| -c }.first(20)
691
+ header = ' # word count %'
692
+ lines = format_freq_lines(top, words.size)
693
+ @message_stream.add_message(role: :system,
694
+ content: "Word frequency (top #{top.size}):\n#{header}\n#{lines.join("\n")}")
695
+ :handled
696
+ end
697
+
698
+ def collect_freq_words
699
+ @message_stream.messages
700
+ .flat_map { |m| m[:content].to_s.downcase.scan(/[a-z']+/) }
701
+ .reject { |w| FREQ_STOP_WORDS.include?(w) || w.length < 2 }
702
+ end
703
+
704
+ def format_freq_lines(top, total)
705
+ top.map.with_index(1) do |(word, count), rank|
706
+ pct = (count.to_f / total * 100).round(1)
707
+ format(FREQ_ROW_FMT, rank: rank, word: word, count: count, pct: pct)
708
+ end
709
+ end
640
710
  end
641
711
  end
642
712
  end
@@ -41,7 +41,11 @@ 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].freeze
45
49
 
46
50
  PERSONALITIES = {
47
51
  'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
@@ -85,6 +89,7 @@ module Legion
85
89
  @multiline_mode = false
86
90
  @speak_mode = false
87
91
  @silent_mode = false
92
+ @draft = nil
88
93
  end
89
94
 
90
95
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -477,6 +482,16 @@ module Legion
477
482
  when '/number' then handle_number(input)
478
483
  when '/speak' then handle_speak(input)
479
484
  when '/silent' then handle_silent
485
+ when '/color' then handle_color(input)
486
+ when '/timestamps' then handle_timestamps(input)
487
+ when '/top' then handle_top
488
+ when '/bottom' then handle_bottom
489
+ when '/head' then handle_head(input)
490
+ when '/tail' then handle_tail(input)
491
+ when '/draft' then handle_draft(input)
492
+ when '/revise' then handle_revise(input)
493
+ when '/mark' then handle_mark(input)
494
+ when '/freq' then handle_freq
480
495
  else :handled
481
496
  end
482
497
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.23'
5
+ VERSION = '0.4.24'
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.24
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity