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 +4 -4
- data/CHANGELOG.md +14 -0
- data/lib/legion/tty/components/message_stream.rb +15 -4
- data/lib/legion/tty/screens/chat/message_commands.rb +95 -0
- data/lib/legion/tty/screens/chat/ui_commands.rb +70 -0
- data/lib/legion/tty/screens/chat.rb +16 -1
- data/lib/legion/tty/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab1a18bdbec9078051e19b84ede94cdaacb600ed12006674dc5696f711230349
|
|
4
|
+
data.tar.gz: 6735876baede0f4fd45637f6a5cb0cef4284f7117298fa96cb97c8ca667a23d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 = ['', "#{
|
|
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
|
|
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
|
data/lib/legion/tty/version.rb
CHANGED