legion-tty 0.4.22 → 0.4.23
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 +12 -0
- data/lib/legion/tty/components/message_stream.rb +20 -6
- data/lib/legion/tty/components/status_bar.rb +8 -1
- data/lib/legion/tty/screens/chat/model_commands.rb +22 -0
- data/lib/legion/tty/screens/chat/ui_commands.rb +96 -0
- data/lib/legion/tty/screens/chat.rb +31 -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: f33a9d4d3640c42054bc91b873ec04898386dc635d5b3097e037c7695a3a9594
|
|
4
|
+
data.tar.gz: 469499bb265ededb47d2c1a03778174b8cae152a163808ba5a606b1aaa3b94ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79090a1ed32486fa0e15f59312d34ee601c8cc92e8d596ccffda4cc7951cedd3d8461801b90669c1a34d22b32caa6c794836d7dc00633636d64c940dfc757633
|
|
7
|
+
data.tar.gz: 3ccb8fde2f3e285cbed4f06ddc46132d9f5fd45bad3c5cc5a80d22bca704c2a8e1e90bf799e8e7095228fe1c1330318156792392a67ddcaba4b3eea9f439d094
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.23] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `/wrap [N|off]` command: set custom word wrap width for message display
|
|
7
|
+
- `/number [on|off]` command: toggle message numbering with `[N]` prefix
|
|
8
|
+
- `/echo <text>` command: add user-defined system messages (notes/markers)
|
|
9
|
+
- `/env` command: show environment info (Ruby version, platform, terminal, PID, Legion gems)
|
|
10
|
+
- `/speak [on|off]` command: toggle text-to-speech for assistant messages (macOS only, via `say`)
|
|
11
|
+
- `/silent` command: toggle silent mode (responses tracked but not displayed), `[SILENT]` indicator
|
|
12
|
+
- `/ls [path]` command: list directory contents with directory markers
|
|
13
|
+
- `/pwd` command: show current working directory
|
|
14
|
+
|
|
3
15
|
## [0.4.22] - 2026-03-19
|
|
4
16
|
|
|
5
17
|
### Added
|
|
@@ -9,7 +9,7 @@ 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
13
|
|
|
14
14
|
HIGHLIGHT_COLOR = "\e[1;33m"
|
|
15
15
|
HIGHLIGHT_RESET = "\e[0m"
|
|
@@ -18,7 +18,10 @@ module Legion
|
|
|
18
18
|
@messages = []
|
|
19
19
|
@scroll_offset = 0
|
|
20
20
|
@mute_system = false
|
|
21
|
+
@silent_mode = false
|
|
21
22
|
@highlights = []
|
|
23
|
+
@wrap_width = nil
|
|
24
|
+
@show_numbers = false
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def add_message(role:, content:)
|
|
@@ -61,7 +64,8 @@ module Legion
|
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
def render(width:, height:)
|
|
64
|
-
|
|
67
|
+
effective_width = @wrap_width || width
|
|
68
|
+
all_lines = build_all_lines(effective_width)
|
|
65
69
|
total = all_lines.size
|
|
66
70
|
start_idx = [total - height - @scroll_offset, 0].max
|
|
67
71
|
start_idx = [start_idx, total].min
|
|
@@ -77,10 +81,11 @@ module Legion
|
|
|
77
81
|
private
|
|
78
82
|
|
|
79
83
|
def build_all_lines(width)
|
|
80
|
-
filtered_messages.flat_map do |msg|
|
|
84
|
+
filtered_messages.each_with_index.flat_map do |msg, idx|
|
|
81
85
|
next [] if @mute_system && msg[:role] == :system
|
|
86
|
+
next [] if @silent_mode && msg[:role] == :assistant
|
|
82
87
|
|
|
83
|
-
render_message(msg, width)
|
|
88
|
+
render_message(msg, width, @show_numbers ? idx + 1 : nil)
|
|
84
89
|
end
|
|
85
90
|
end
|
|
86
91
|
|
|
@@ -99,8 +104,17 @@ module Legion
|
|
|
99
104
|
end
|
|
100
105
|
end
|
|
101
106
|
|
|
102
|
-
def render_message(msg, width)
|
|
103
|
-
role_lines(msg, width) + panel_lines(msg, width)
|
|
107
|
+
def render_message(msg, width, number = nil)
|
|
108
|
+
lines = role_lines(msg, width) + panel_lines(msg, width)
|
|
109
|
+
prepend_number(lines, number)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def prepend_number(lines, number)
|
|
113
|
+
return lines unless number
|
|
114
|
+
|
|
115
|
+
lines.each_with_index.map do |line, i|
|
|
116
|
+
i == 1 ? "[#{number}] #{line}" : line
|
|
117
|
+
end
|
|
104
118
|
end
|
|
105
119
|
|
|
106
120
|
def role_lines(msg, width)
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -471,10 +471,75 @@ module Legion
|
|
|
471
471
|
end
|
|
472
472
|
end
|
|
473
473
|
|
|
474
|
+
def handle_wrap(input)
|
|
475
|
+
arg = input.split(nil, 2)[1]&.strip
|
|
476
|
+
if arg.nil?
|
|
477
|
+
status = @message_stream.wrap_width ? "#{@message_stream.wrap_width} columns" : 'off'
|
|
478
|
+
@message_stream.add_message(role: :system, content: "Wrap: #{status}")
|
|
479
|
+
elsif arg == 'off'
|
|
480
|
+
@message_stream.wrap_width = nil
|
|
481
|
+
@message_stream.add_message(role: :system, content: 'Word wrap disabled.')
|
|
482
|
+
else
|
|
483
|
+
n = arg.to_i
|
|
484
|
+
if n >= 20
|
|
485
|
+
@message_stream.wrap_width = n
|
|
486
|
+
@message_stream.add_message(role: :system, content: "Word wrap set to #{n} columns.")
|
|
487
|
+
else
|
|
488
|
+
@message_stream.add_message(role: :system, content: 'Usage: /wrap [N|off]')
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
:handled
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def handle_number(input)
|
|
495
|
+
arg = input.split(nil, 2)[1]&.strip
|
|
496
|
+
case arg
|
|
497
|
+
when 'on'
|
|
498
|
+
@message_stream.show_numbers = true
|
|
499
|
+
@message_stream.add_message(role: :system, content: 'Message numbering ON.')
|
|
500
|
+
when 'off'
|
|
501
|
+
@message_stream.show_numbers = false
|
|
502
|
+
@message_stream.add_message(role: :system, content: 'Message numbering OFF.')
|
|
503
|
+
else
|
|
504
|
+
@message_stream.show_numbers = !@message_stream.show_numbers
|
|
505
|
+
state = @message_stream.show_numbers ? 'ON' : 'OFF'
|
|
506
|
+
@message_stream.add_message(role: :system, content: "Message numbering #{state}.")
|
|
507
|
+
end
|
|
508
|
+
:handled
|
|
509
|
+
end
|
|
510
|
+
|
|
474
511
|
def safe_calc_expr?(expr)
|
|
475
512
|
CALC_SAFE_PATTERN.match?(expr) || CALC_MATH_PATTERN.match?(expr)
|
|
476
513
|
end
|
|
477
514
|
|
|
515
|
+
def handle_echo(input)
|
|
516
|
+
text = input.split(nil, 2)[1]&.strip
|
|
517
|
+
unless text && !text.empty?
|
|
518
|
+
@message_stream.add_message(role: :system, content: 'Usage: /echo <text>')
|
|
519
|
+
return :handled
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
@message_stream.add_message(role: :system, content: text)
|
|
523
|
+
:handled
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def handle_env
|
|
527
|
+
width = terminal_width
|
|
528
|
+
height = terminal_height
|
|
529
|
+
legion_gems = Gem::Specification.select { |s| s.name.start_with?('legion-', 'lex-') }
|
|
530
|
+
.map { |s| "#{s.name} #{s.version}" }
|
|
531
|
+
.sort
|
|
532
|
+
lines = [
|
|
533
|
+
"Ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})",
|
|
534
|
+
"Terminal: #{width}x#{height}",
|
|
535
|
+
"PID: #{::Process.pid}",
|
|
536
|
+
"TTY: legion-tty v#{Legion::TTY::VERSION}",
|
|
537
|
+
"Gems (#{legion_gems.size}): #{legion_gems.join(', ')}"
|
|
538
|
+
]
|
|
539
|
+
@message_stream.add_message(role: :system, content: lines.join("\n"))
|
|
540
|
+
:handled
|
|
541
|
+
end
|
|
542
|
+
|
|
478
543
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
479
544
|
def handle_summary
|
|
480
545
|
msgs = @message_stream.messages
|
|
@@ -541,6 +606,37 @@ module Legion
|
|
|
541
606
|
rescue StandardError => e
|
|
542
607
|
raise "command failed: #{e.message}"
|
|
543
608
|
end
|
|
609
|
+
|
|
610
|
+
# rubocop:disable Metrics/AbcSize
|
|
611
|
+
def handle_ls(input)
|
|
612
|
+
path = File.expand_path(input.split(nil, 2)[1]&.strip || '.')
|
|
613
|
+
entries = Dir.entries(path).sort.reject { |e| ['.', '..'].include?(e) }
|
|
614
|
+
entries = entries.map { |e| File.directory?(File.join(path, e)) ? "#{e}/" : e }
|
|
615
|
+
@message_stream.add_message(role: :system, content: "#{path}:\n#{entries.join("\n")}")
|
|
616
|
+
:handled
|
|
617
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
618
|
+
@message_stream.add_message(role: :system, content: "ls: #{e.message}")
|
|
619
|
+
:handled
|
|
620
|
+
end
|
|
621
|
+
# rubocop:enable Metrics/AbcSize
|
|
622
|
+
|
|
623
|
+
def handle_pwd
|
|
624
|
+
@message_stream.add_message(role: :system, content: Dir.pwd)
|
|
625
|
+
:handled
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def handle_silent
|
|
629
|
+
@silent_mode = !@silent_mode
|
|
630
|
+
@message_stream.silent_mode = @silent_mode
|
|
631
|
+
if @silent_mode
|
|
632
|
+
@status_bar.update(silent: true)
|
|
633
|
+
@message_stream.add_message(role: :system, content: 'Silent mode ON -- assistant responses hidden.')
|
|
634
|
+
else
|
|
635
|
+
@status_bar.update(silent: false)
|
|
636
|
+
@message_stream.add_message(role: :system, content: 'Silent mode OFF -- assistant responses visible.')
|
|
637
|
+
end
|
|
638
|
+
:handled
|
|
639
|
+
end
|
|
544
640
|
end
|
|
545
641
|
end
|
|
546
642
|
end
|
|
@@ -37,7 +37,11 @@ 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].freeze
|
|
41
45
|
|
|
42
46
|
PERSONALITIES = {
|
|
43
47
|
'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
|
|
@@ -79,6 +83,8 @@ module Legion
|
|
|
79
83
|
@last_user_input = nil
|
|
80
84
|
@highlights = []
|
|
81
85
|
@multiline_mode = false
|
|
86
|
+
@speak_mode = false
|
|
87
|
+
@silent_mode = false
|
|
82
88
|
end
|
|
83
89
|
|
|
84
90
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -244,20 +250,35 @@ module Legion
|
|
|
244
250
|
send_via_direct(message)
|
|
245
251
|
end
|
|
246
252
|
|
|
253
|
+
# rubocop:disable Metrics/AbcSize
|
|
247
254
|
def send_via_direct(message)
|
|
248
255
|
return unless @llm_chat
|
|
249
256
|
|
|
250
257
|
@status_bar.update(thinking: true)
|
|
251
258
|
render_screen
|
|
252
259
|
start_time = Time.now
|
|
260
|
+
response_text = +''
|
|
253
261
|
response = @llm_chat.ask(message) do |chunk|
|
|
254
262
|
@status_bar.update(thinking: false)
|
|
255
|
-
|
|
263
|
+
if chunk.content
|
|
264
|
+
response_text << chunk.content
|
|
265
|
+
@message_stream.append_streaming(chunk.content)
|
|
266
|
+
end
|
|
256
267
|
render_screen
|
|
257
268
|
end
|
|
258
269
|
record_response_time(Time.now - start_time)
|
|
259
270
|
@status_bar.update(thinking: false)
|
|
260
271
|
track_response_tokens(response)
|
|
272
|
+
speak_response(response_text) if @speak_mode
|
|
273
|
+
end
|
|
274
|
+
# rubocop:enable Metrics/AbcSize
|
|
275
|
+
|
|
276
|
+
def speak_response(text)
|
|
277
|
+
return unless RUBY_PLATFORM =~ /darwin/
|
|
278
|
+
|
|
279
|
+
::Process.spawn('say', text[0..500], err: '/dev/null', out: '/dev/null')
|
|
280
|
+
rescue StandardError
|
|
281
|
+
nil
|
|
261
282
|
end
|
|
262
283
|
|
|
263
284
|
def record_response_time(elapsed)
|
|
@@ -448,6 +469,14 @@ module Legion
|
|
|
448
469
|
when '/archives' then handle_archives
|
|
449
470
|
when '/calc' then handle_calc(input)
|
|
450
471
|
when '/rand' then handle_rand(input)
|
|
472
|
+
when '/echo' then handle_echo(input)
|
|
473
|
+
when '/env' then handle_env
|
|
474
|
+
when '/ls' then handle_ls(input)
|
|
475
|
+
when '/pwd' then handle_pwd
|
|
476
|
+
when '/wrap' then handle_wrap(input)
|
|
477
|
+
when '/number' then handle_number(input)
|
|
478
|
+
when '/speak' then handle_speak(input)
|
|
479
|
+
when '/silent' then handle_silent
|
|
451
480
|
else :handled
|
|
452
481
|
end
|
|
453
482
|
end
|
data/lib/legion/tty/version.rb
CHANGED