legion-tty 0.4.22 → 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 +26 -0
- data/lib/legion/tty/components/message_stream.rb +34 -9
- data/lib/legion/tty/components/status_bar.rb +8 -1
- data/lib/legion/tty/screens/chat/message_commands.rb +95 -0
- data/lib/legion/tty/screens/chat/model_commands.rb +22 -0
- data/lib/legion/tty/screens/chat/ui_commands.rb +166 -0
- data/lib/legion/tty/screens/chat.rb +46 -2
- 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,31 @@
|
|
|
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
|
+
|
|
17
|
+
## [0.4.23] - 2026-03-19
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `/wrap [N|off]` command: set custom word wrap width for message display
|
|
21
|
+
- `/number [on|off]` command: toggle message numbering with `[N]` prefix
|
|
22
|
+
- `/echo <text>` command: add user-defined system messages (notes/markers)
|
|
23
|
+
- `/env` command: show environment info (Ruby version, platform, terminal, PID, Legion gems)
|
|
24
|
+
- `/speak [on|off]` command: toggle text-to-speech for assistant messages (macOS only, via `say`)
|
|
25
|
+
- `/silent` command: toggle silent mode (responses tracked but not displayed), `[SILENT]` indicator
|
|
26
|
+
- `/ls [path]` command: list directory contents with directory markers
|
|
27
|
+
- `/pwd` command: show current working directory
|
|
28
|
+
|
|
3
29
|
## [0.4.22] - 2026-03-19
|
|
4
30
|
|
|
5
31
|
### 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, :highlights, :filter, :truncate_limit
|
|
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"
|
|
@@ -18,7 +19,12 @@ module Legion
|
|
|
18
19
|
@messages = []
|
|
19
20
|
@scroll_offset = 0
|
|
20
21
|
@mute_system = false
|
|
22
|
+
@silent_mode = false
|
|
21
23
|
@highlights = []
|
|
24
|
+
@wrap_width = nil
|
|
25
|
+
@show_numbers = false
|
|
26
|
+
@colorize = true
|
|
27
|
+
@show_timestamps = true
|
|
22
28
|
end
|
|
23
29
|
|
|
24
30
|
def add_message(role:, content:)
|
|
@@ -61,11 +67,13 @@ module Legion
|
|
|
61
67
|
end
|
|
62
68
|
|
|
63
69
|
def render(width:, height:)
|
|
64
|
-
|
|
70
|
+
effective_width = @wrap_width || width
|
|
71
|
+
all_lines = build_all_lines(effective_width)
|
|
65
72
|
total = all_lines.size
|
|
66
73
|
start_idx = [total - height - @scroll_offset, 0].max
|
|
67
74
|
start_idx = [start_idx, total].min
|
|
68
75
|
result = all_lines[start_idx, height] || []
|
|
76
|
+
result = result.map { |l| strip_ansi(l) } unless @colorize
|
|
69
77
|
@last_visible_count = result.size
|
|
70
78
|
result
|
|
71
79
|
end
|
|
@@ -77,10 +85,11 @@ module Legion
|
|
|
77
85
|
private
|
|
78
86
|
|
|
79
87
|
def build_all_lines(width)
|
|
80
|
-
filtered_messages.flat_map do |msg|
|
|
88
|
+
filtered_messages.each_with_index.flat_map do |msg, idx|
|
|
81
89
|
next [] if @mute_system && msg[:role] == :system
|
|
90
|
+
next [] if @silent_mode && msg[:role] == :assistant
|
|
82
91
|
|
|
83
|
-
render_message(msg, width)
|
|
92
|
+
render_message(msg, width, @show_numbers ? idx + 1 : nil)
|
|
84
93
|
end
|
|
85
94
|
end
|
|
86
95
|
|
|
@@ -99,8 +108,17 @@ module Legion
|
|
|
99
108
|
end
|
|
100
109
|
end
|
|
101
110
|
|
|
102
|
-
def render_message(msg, width)
|
|
103
|
-
role_lines(msg, width) + panel_lines(msg, width)
|
|
111
|
+
def render_message(msg, width, number = nil)
|
|
112
|
+
lines = role_lines(msg, width) + panel_lines(msg, width)
|
|
113
|
+
prepend_number(lines, number)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def prepend_number(lines, number)
|
|
117
|
+
return lines unless number
|
|
118
|
+
|
|
119
|
+
lines.each_with_index.map do |line, i|
|
|
120
|
+
i == 1 ? "[#{number}] #{line}" : line
|
|
121
|
+
end
|
|
104
122
|
end
|
|
105
123
|
|
|
106
124
|
def role_lines(msg, width)
|
|
@@ -114,15 +132,18 @@ module Legion
|
|
|
114
132
|
end
|
|
115
133
|
|
|
116
134
|
def user_lines(msg, _width)
|
|
117
|
-
ts = format_timestamp(msg[:timestamp])
|
|
118
|
-
header = "#{Theme.c(:accent, 'You')} #{Theme.c(:muted, ts)}"
|
|
119
135
|
content = apply_highlights(msg[:content].to_s)
|
|
120
|
-
lines = ['', "#{
|
|
136
|
+
lines = ['', "#{user_header(msg[:timestamp])}: #{content}"]
|
|
121
137
|
lines << reaction_line(msg) if msg[:reactions]&.any?
|
|
122
138
|
lines.concat(annotation_lines(msg)) if msg[:annotations]&.any?
|
|
123
139
|
lines
|
|
124
140
|
end
|
|
125
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
|
+
|
|
126
147
|
def format_timestamp(time)
|
|
127
148
|
return '' unless time
|
|
128
149
|
|
|
@@ -195,6 +216,10 @@ module Legion
|
|
|
195
216
|
panel.instance_variable_set(:@result, result) if result
|
|
196
217
|
panel.instance_variable_set(:@error, error) if error
|
|
197
218
|
end
|
|
219
|
+
|
|
220
|
+
def strip_ansi(text)
|
|
221
|
+
text.gsub(/\e\[[0-9;]*m/, '')
|
|
222
|
+
end
|
|
198
223
|
end
|
|
199
224
|
# rubocop:enable Metrics/ClassLength
|
|
200
225
|
end
|
|
@@ -10,7 +10,7 @@ module Legion
|
|
|
10
10
|
class StatusBar
|
|
11
11
|
def initialize
|
|
12
12
|
@state = { model: nil, tokens: 0, cost: 0.0, session: 'default', thinking: false, plan_mode: false,
|
|
13
|
-
debug_mode: false, message_count: 0, multiline: false }
|
|
13
|
+
debug_mode: false, message_count: 0, multiline: false, silent: false }
|
|
14
14
|
@notifications = []
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -42,6 +42,7 @@ module Legion
|
|
|
42
42
|
[
|
|
43
43
|
model_segment,
|
|
44
44
|
plan_segment,
|
|
45
|
+
silent_segment,
|
|
45
46
|
multiline_segment,
|
|
46
47
|
debug_segment,
|
|
47
48
|
thinking_segment,
|
|
@@ -70,6 +71,12 @@ module Legion
|
|
|
70
71
|
Theme.c(:accent, '[ML]')
|
|
71
72
|
end
|
|
72
73
|
|
|
74
|
+
def silent_segment
|
|
75
|
+
return nil unless @state[:silent]
|
|
76
|
+
|
|
77
|
+
Theme.c(:warning, '[SILENT]')
|
|
78
|
+
end
|
|
79
|
+
|
|
73
80
|
def debug_segment
|
|
74
81
|
return nil unless @state[:debug_mode]
|
|
75
82
|
|
|
@@ -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
|
|
@@ -132,6 +132,28 @@ module Legion
|
|
|
132
132
|
send_to_llm(@last_user_input)
|
|
133
133
|
:handled
|
|
134
134
|
end
|
|
135
|
+
|
|
136
|
+
def handle_speak(input)
|
|
137
|
+
unless RUBY_PLATFORM =~ /darwin/
|
|
138
|
+
@message_stream.add_message(role: :system, content: 'Text-to-speech is only available on macOS.')
|
|
139
|
+
return :handled
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
arg = input.split(nil, 2)[1]&.strip&.downcase
|
|
143
|
+
case arg
|
|
144
|
+
when 'on'
|
|
145
|
+
@speak_mode = true
|
|
146
|
+
@message_stream.add_message(role: :system, content: 'Text-to-speech ON.')
|
|
147
|
+
when 'off'
|
|
148
|
+
@speak_mode = false
|
|
149
|
+
@message_stream.add_message(role: :system, content: 'Text-to-speech OFF.')
|
|
150
|
+
else
|
|
151
|
+
@speak_mode = !@speak_mode
|
|
152
|
+
state = @speak_mode ? 'ON' : 'OFF'
|
|
153
|
+
@message_stream.add_message(role: :system, content: "Text-to-speech #{state}.")
|
|
154
|
+
end
|
|
155
|
+
:handled
|
|
156
|
+
end
|
|
135
157
|
end
|
|
136
158
|
end
|
|
137
159
|
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
|
|
|
@@ -471,10 +477,75 @@ module Legion
|
|
|
471
477
|
end
|
|
472
478
|
end
|
|
473
479
|
|
|
480
|
+
def handle_wrap(input)
|
|
481
|
+
arg = input.split(nil, 2)[1]&.strip
|
|
482
|
+
if arg.nil?
|
|
483
|
+
status = @message_stream.wrap_width ? "#{@message_stream.wrap_width} columns" : 'off'
|
|
484
|
+
@message_stream.add_message(role: :system, content: "Wrap: #{status}")
|
|
485
|
+
elsif arg == 'off'
|
|
486
|
+
@message_stream.wrap_width = nil
|
|
487
|
+
@message_stream.add_message(role: :system, content: 'Word wrap disabled.')
|
|
488
|
+
else
|
|
489
|
+
n = arg.to_i
|
|
490
|
+
if n >= 20
|
|
491
|
+
@message_stream.wrap_width = n
|
|
492
|
+
@message_stream.add_message(role: :system, content: "Word wrap set to #{n} columns.")
|
|
493
|
+
else
|
|
494
|
+
@message_stream.add_message(role: :system, content: 'Usage: /wrap [N|off]')
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
:handled
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def handle_number(input)
|
|
501
|
+
arg = input.split(nil, 2)[1]&.strip
|
|
502
|
+
case arg
|
|
503
|
+
when 'on'
|
|
504
|
+
@message_stream.show_numbers = true
|
|
505
|
+
@message_stream.add_message(role: :system, content: 'Message numbering ON.')
|
|
506
|
+
when 'off'
|
|
507
|
+
@message_stream.show_numbers = false
|
|
508
|
+
@message_stream.add_message(role: :system, content: 'Message numbering OFF.')
|
|
509
|
+
else
|
|
510
|
+
@message_stream.show_numbers = !@message_stream.show_numbers
|
|
511
|
+
state = @message_stream.show_numbers ? 'ON' : 'OFF'
|
|
512
|
+
@message_stream.add_message(role: :system, content: "Message numbering #{state}.")
|
|
513
|
+
end
|
|
514
|
+
:handled
|
|
515
|
+
end
|
|
516
|
+
|
|
474
517
|
def safe_calc_expr?(expr)
|
|
475
518
|
CALC_SAFE_PATTERN.match?(expr) || CALC_MATH_PATTERN.match?(expr)
|
|
476
519
|
end
|
|
477
520
|
|
|
521
|
+
def handle_echo(input)
|
|
522
|
+
text = input.split(nil, 2)[1]&.strip
|
|
523
|
+
unless text && !text.empty?
|
|
524
|
+
@message_stream.add_message(role: :system, content: 'Usage: /echo <text>')
|
|
525
|
+
return :handled
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
@message_stream.add_message(role: :system, content: text)
|
|
529
|
+
:handled
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def handle_env
|
|
533
|
+
width = terminal_width
|
|
534
|
+
height = terminal_height
|
|
535
|
+
legion_gems = Gem::Specification.select { |s| s.name.start_with?('legion-', 'lex-') }
|
|
536
|
+
.map { |s| "#{s.name} #{s.version}" }
|
|
537
|
+
.sort
|
|
538
|
+
lines = [
|
|
539
|
+
"Ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})",
|
|
540
|
+
"Terminal: #{width}x#{height}",
|
|
541
|
+
"PID: #{::Process.pid}",
|
|
542
|
+
"TTY: legion-tty v#{Legion::TTY::VERSION}",
|
|
543
|
+
"Gems (#{legion_gems.size}): #{legion_gems.join(', ')}"
|
|
544
|
+
]
|
|
545
|
+
@message_stream.add_message(role: :system, content: lines.join("\n"))
|
|
546
|
+
:handled
|
|
547
|
+
end
|
|
548
|
+
|
|
478
549
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
479
550
|
def handle_summary
|
|
480
551
|
msgs = @message_stream.messages
|
|
@@ -541,6 +612,101 @@ module Legion
|
|
|
541
612
|
rescue StandardError => e
|
|
542
613
|
raise "command failed: #{e.message}"
|
|
543
614
|
end
|
|
615
|
+
|
|
616
|
+
# rubocop:disable Metrics/AbcSize
|
|
617
|
+
def handle_ls(input)
|
|
618
|
+
path = File.expand_path(input.split(nil, 2)[1]&.strip || '.')
|
|
619
|
+
entries = Dir.entries(path).sort.reject { |e| ['.', '..'].include?(e) }
|
|
620
|
+
entries = entries.map { |e| File.directory?(File.join(path, e)) ? "#{e}/" : e }
|
|
621
|
+
@message_stream.add_message(role: :system, content: "#{path}:\n#{entries.join("\n")}")
|
|
622
|
+
:handled
|
|
623
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
624
|
+
@message_stream.add_message(role: :system, content: "ls: #{e.message}")
|
|
625
|
+
:handled
|
|
626
|
+
end
|
|
627
|
+
# rubocop:enable Metrics/AbcSize
|
|
628
|
+
|
|
629
|
+
def handle_pwd
|
|
630
|
+
@message_stream.add_message(role: :system, content: Dir.pwd)
|
|
631
|
+
:handled
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def handle_silent
|
|
635
|
+
@silent_mode = !@silent_mode
|
|
636
|
+
@message_stream.silent_mode = @silent_mode
|
|
637
|
+
if @silent_mode
|
|
638
|
+
@status_bar.update(silent: true)
|
|
639
|
+
@message_stream.add_message(role: :system, content: 'Silent mode ON -- assistant responses hidden.')
|
|
640
|
+
else
|
|
641
|
+
@status_bar.update(silent: false)
|
|
642
|
+
@message_stream.add_message(role: :system, content: 'Silent mode OFF -- assistant responses visible.')
|
|
643
|
+
end
|
|
644
|
+
:handled
|
|
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
|
|
544
710
|
end
|
|
545
711
|
end
|
|
546
712
|
end
|
|
@@ -37,7 +37,15 @@ module Legion
|
|
|
37
37
|
/annotate /annotations /filter /truncate
|
|
38
38
|
/tee /pipe
|
|
39
39
|
/archive /archives
|
|
40
|
-
/calc /rand
|
|
40
|
+
/calc /rand
|
|
41
|
+
/echo /env
|
|
42
|
+
/ls /pwd
|
|
43
|
+
/wrap /number
|
|
44
|
+
/speak /silent
|
|
45
|
+
/color /timestamps
|
|
46
|
+
/top /bottom /head /tail
|
|
47
|
+
/draft /revise
|
|
48
|
+
/mark /freq].freeze
|
|
41
49
|
|
|
42
50
|
PERSONALITIES = {
|
|
43
51
|
'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
|
|
@@ -79,6 +87,9 @@ module Legion
|
|
|
79
87
|
@last_user_input = nil
|
|
80
88
|
@highlights = []
|
|
81
89
|
@multiline_mode = false
|
|
90
|
+
@speak_mode = false
|
|
91
|
+
@silent_mode = false
|
|
92
|
+
@draft = nil
|
|
82
93
|
end
|
|
83
94
|
|
|
84
95
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -244,20 +255,35 @@ module Legion
|
|
|
244
255
|
send_via_direct(message)
|
|
245
256
|
end
|
|
246
257
|
|
|
258
|
+
# rubocop:disable Metrics/AbcSize
|
|
247
259
|
def send_via_direct(message)
|
|
248
260
|
return unless @llm_chat
|
|
249
261
|
|
|
250
262
|
@status_bar.update(thinking: true)
|
|
251
263
|
render_screen
|
|
252
264
|
start_time = Time.now
|
|
265
|
+
response_text = +''
|
|
253
266
|
response = @llm_chat.ask(message) do |chunk|
|
|
254
267
|
@status_bar.update(thinking: false)
|
|
255
|
-
|
|
268
|
+
if chunk.content
|
|
269
|
+
response_text << chunk.content
|
|
270
|
+
@message_stream.append_streaming(chunk.content)
|
|
271
|
+
end
|
|
256
272
|
render_screen
|
|
257
273
|
end
|
|
258
274
|
record_response_time(Time.now - start_time)
|
|
259
275
|
@status_bar.update(thinking: false)
|
|
260
276
|
track_response_tokens(response)
|
|
277
|
+
speak_response(response_text) if @speak_mode
|
|
278
|
+
end
|
|
279
|
+
# rubocop:enable Metrics/AbcSize
|
|
280
|
+
|
|
281
|
+
def speak_response(text)
|
|
282
|
+
return unless RUBY_PLATFORM =~ /darwin/
|
|
283
|
+
|
|
284
|
+
::Process.spawn('say', text[0..500], err: '/dev/null', out: '/dev/null')
|
|
285
|
+
rescue StandardError
|
|
286
|
+
nil
|
|
261
287
|
end
|
|
262
288
|
|
|
263
289
|
def record_response_time(elapsed)
|
|
@@ -448,6 +474,24 @@ module Legion
|
|
|
448
474
|
when '/archives' then handle_archives
|
|
449
475
|
when '/calc' then handle_calc(input)
|
|
450
476
|
when '/rand' then handle_rand(input)
|
|
477
|
+
when '/echo' then handle_echo(input)
|
|
478
|
+
when '/env' then handle_env
|
|
479
|
+
when '/ls' then handle_ls(input)
|
|
480
|
+
when '/pwd' then handle_pwd
|
|
481
|
+
when '/wrap' then handle_wrap(input)
|
|
482
|
+
when '/number' then handle_number(input)
|
|
483
|
+
when '/speak' then handle_speak(input)
|
|
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
|
|
451
495
|
else :handled
|
|
452
496
|
end
|
|
453
497
|
end
|
data/lib/legion/tty/version.rb
CHANGED