legion-tty 0.4.15 → 0.4.17
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 +16 -0
- data/lib/legion/tty/screens/chat/custom_commands.rb +41 -0
- data/lib/legion/tty/screens/chat/message_commands.rb +88 -0
- data/lib/legion/tty/screens/chat/ui_commands.rb +26 -0
- data/lib/legion/tty/screens/chat.rb +20 -1
- data/lib/legion/tty/screens/config.rb +21 -1
- data/lib/legion/tty/screens/extensions.rb +54 -19
- 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: 764baccaffa8d9863f8cb85248c15775fce561f729b74d70ff0b597f343eb0f5
|
|
4
|
+
data.tar.gz: 39cacbd736ae3aacbeb54c429428c55cbde2a9325654f6d32e3e272c60586f7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f91d4641d6ca7566d9229c1ae644b820f7963793a5a93d92be56547d178f5c205ddcf28ed2b0303fd545c86c85bfeda99f1ebe226392940e748f504a6045868c
|
|
7
|
+
data.tar.gz: 03c2a6d201175628179ad926a6453c9446bb1bcdfd999dafafa38dc59aa0a0a422ebe654a89b8751f5f745e45a2c51ca28bf84ee9ba60c368120b9f13e6b6e97
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.17] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `/template` command: 8 predefined prompt templates (explain, review, summarize, refactor, test, debug, translate, compare)
|
|
7
|
+
- `/fav` and `/favs` commands: persistent favorites saved to `~/.legionio/favorites.json`
|
|
8
|
+
- `/log [N]` command: view last N lines of boot log (default 20)
|
|
9
|
+
- `/version` command: show legion-tty version, Ruby version, and platform
|
|
10
|
+
|
|
11
|
+
## [0.4.16] - 2026-03-19
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Extensions screen category filter: 'f' cycles through Core/AI/Service/Agentic/Other, 'c' clears
|
|
15
|
+
- Config screen backup: 'b' creates .bak copy, auto-backup before edits
|
|
16
|
+
- `/repeat` command: re-execute the last slash command
|
|
17
|
+
- `/count <pattern>` command: count messages matching a pattern with per-role breakdown
|
|
18
|
+
|
|
3
19
|
## [0.4.15] - 2026-03-19
|
|
4
20
|
|
|
5
21
|
### Added
|
|
@@ -6,8 +6,49 @@ module Legion
|
|
|
6
6
|
class Chat < Base
|
|
7
7
|
# rubocop:disable Metrics/ModuleLength
|
|
8
8
|
module CustomCommands
|
|
9
|
+
TEMPLATES = {
|
|
10
|
+
'explain' => 'Explain the following concept in simple terms: ',
|
|
11
|
+
'review' => "Review this code for bugs, security issues, and improvements:\n```\n",
|
|
12
|
+
'summarize' => "Summarize the following text in 3 bullet points:\n",
|
|
13
|
+
'refactor' => "Refactor this code for readability and performance:\n```\n",
|
|
14
|
+
'test' => "Write unit tests for this code:\n```\n",
|
|
15
|
+
'debug' => "Help me debug this error:\n",
|
|
16
|
+
'translate' => 'Translate the following to ',
|
|
17
|
+
'compare' => "Compare and contrast the following:\n"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
9
20
|
private
|
|
10
21
|
|
|
22
|
+
# rubocop:disable Metrics/MethodLength
|
|
23
|
+
def handle_template(input)
|
|
24
|
+
name = input.split(nil, 2)[1]
|
|
25
|
+
unless name
|
|
26
|
+
lines = TEMPLATES.map { |k, v| " #{k}: #{v[0, 60]}" }
|
|
27
|
+
@message_stream.add_message(
|
|
28
|
+
role: :system,
|
|
29
|
+
content: "Available templates (#{TEMPLATES.size}):\n#{lines.join("\n")}\n\nUsage: /template <name>"
|
|
30
|
+
)
|
|
31
|
+
return :handled
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
template = TEMPLATES[name]
|
|
35
|
+
unless template
|
|
36
|
+
available = TEMPLATES.keys.join(', ')
|
|
37
|
+
@message_stream.add_message(
|
|
38
|
+
role: :system,
|
|
39
|
+
content: "Template '#{name}' not found. Available: #{available}"
|
|
40
|
+
)
|
|
41
|
+
return :handled
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@message_stream.add_message(
|
|
45
|
+
role: :system,
|
|
46
|
+
content: "Template '#{name}':\n#{template}"
|
|
47
|
+
)
|
|
48
|
+
:handled
|
|
49
|
+
end
|
|
50
|
+
# rubocop:enable Metrics/MethodLength
|
|
51
|
+
|
|
11
52
|
def handle_alias(input)
|
|
12
53
|
parts = input.split(nil, 3)
|
|
13
54
|
if parts.size < 2
|
|
@@ -226,6 +226,28 @@ module Legion
|
|
|
226
226
|
:handled
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
+
def handle_count(input)
|
|
230
|
+
query = input.split(nil, 2)[1]
|
|
231
|
+
unless query
|
|
232
|
+
@message_stream.add_message(role: :system, content: 'Usage: /count <pattern>')
|
|
233
|
+
return :handled
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
results = search_messages(query)
|
|
237
|
+
if results.empty?
|
|
238
|
+
@message_stream.add_message(role: :system, content: "0 messages matching '#{query}'.")
|
|
239
|
+
else
|
|
240
|
+
breakdown = results.group_by { |m| m[:role] }
|
|
241
|
+
.map { |role, msgs| "#{role}: #{msgs.size}" }
|
|
242
|
+
.join(', ')
|
|
243
|
+
@message_stream.add_message(
|
|
244
|
+
role: :system,
|
|
245
|
+
content: "#{results.size} message(s) matching '#{query}' (#{breakdown})."
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
:handled
|
|
249
|
+
end
|
|
250
|
+
|
|
229
251
|
def search_messages(query)
|
|
230
252
|
pattern = query.downcase
|
|
231
253
|
@message_stream.messages.select do |msg|
|
|
@@ -276,6 +298,72 @@ module Legion
|
|
|
276
298
|
)
|
|
277
299
|
end
|
|
278
300
|
end
|
|
301
|
+
|
|
302
|
+
def favorites_file
|
|
303
|
+
File.expand_path('~/.legionio/favorites.json')
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def load_favorites
|
|
307
|
+
return [] unless File.exist?(favorites_file)
|
|
308
|
+
|
|
309
|
+
raw = File.read(favorites_file)
|
|
310
|
+
parsed = ::JSON.parse(raw, symbolize_names: true)
|
|
311
|
+
parsed.is_a?(Array) ? parsed : []
|
|
312
|
+
rescue StandardError
|
|
313
|
+
[]
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def save_favorites(favs)
|
|
317
|
+
require 'fileutils'
|
|
318
|
+
FileUtils.mkdir_p(File.dirname(favorites_file))
|
|
319
|
+
File.write(favorites_file, ::JSON.generate(favs))
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
323
|
+
def handle_fav(input)
|
|
324
|
+
idx_str = input.split(nil, 2)[1]
|
|
325
|
+
msg = if idx_str
|
|
326
|
+
@message_stream.messages[idx_str.to_i]
|
|
327
|
+
else
|
|
328
|
+
@message_stream.messages.reverse.find { |m| m[:role] == :assistant }
|
|
329
|
+
end
|
|
330
|
+
unless msg
|
|
331
|
+
@message_stream.add_message(role: :system, content: 'No message to favorite.')
|
|
332
|
+
return :handled
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
@favorites ||= []
|
|
336
|
+
entry = {
|
|
337
|
+
role: msg[:role],
|
|
338
|
+
content: msg[:content].to_s,
|
|
339
|
+
saved_at: Time.now.iso8601,
|
|
340
|
+
session: @session_name
|
|
341
|
+
}
|
|
342
|
+
@favorites << entry
|
|
343
|
+
save_favorites(load_favorites + [entry])
|
|
344
|
+
preview = truncate_text(msg[:content].to_s, 60)
|
|
345
|
+
@message_stream.add_message(role: :system, content: "Favorited: #{preview}")
|
|
346
|
+
:handled
|
|
347
|
+
end
|
|
348
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
349
|
+
|
|
350
|
+
def handle_favs
|
|
351
|
+
all_favs = load_favorites
|
|
352
|
+
if all_favs.empty?
|
|
353
|
+
@message_stream.add_message(role: :system, content: 'No favorites saved.')
|
|
354
|
+
return :handled
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
lines = all_favs.each_with_index.map do |fav, i|
|
|
358
|
+
saved = fav[:saved_at] || ''
|
|
359
|
+
" #{i + 1}. [#{fav[:role]}] #{truncate_text(fav[:content].to_s, 60)} (#{saved})"
|
|
360
|
+
end
|
|
361
|
+
@message_stream.add_message(
|
|
362
|
+
role: :system,
|
|
363
|
+
content: "Favorites (#{all_favs.size}):\n#{lines.join("\n")}"
|
|
364
|
+
)
|
|
365
|
+
:handled
|
|
366
|
+
end
|
|
279
367
|
end
|
|
280
368
|
# rubocop:enable Metrics/ModuleLength
|
|
281
369
|
end
|
|
@@ -277,6 +277,32 @@ module Legion
|
|
|
277
277
|
def format_stat_number(num)
|
|
278
278
|
num.to_s.chars.reverse.each_slice(3).map(&:join).join(',').reverse
|
|
279
279
|
end
|
|
280
|
+
|
|
281
|
+
def handle_log(input)
|
|
282
|
+
n = (input.split(nil, 2)[1] || '20').to_i.clamp(1, 500)
|
|
283
|
+
log_path = File.expand_path('~/.legionio/logs/tty-boot.log')
|
|
284
|
+
unless File.exist?(log_path)
|
|
285
|
+
@message_stream.add_message(role: :system, content: 'No boot log found.')
|
|
286
|
+
return :handled
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
lines = File.readlines(log_path, chomp: true).last(n)
|
|
290
|
+
@message_stream.add_message(
|
|
291
|
+
role: :system,
|
|
292
|
+
content: "Boot log (last #{lines.size} lines):\n#{lines.join("\n")}"
|
|
293
|
+
)
|
|
294
|
+
:handled
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def handle_version
|
|
298
|
+
ruby_ver = RUBY_VERSION
|
|
299
|
+
platform = RUBY_PLATFORM
|
|
300
|
+
@message_stream.add_message(
|
|
301
|
+
role: :system,
|
|
302
|
+
content: "legion-tty v#{Legion::TTY::VERSION}\nRuby: #{ruby_ver}\nPlatform: #{platform}"
|
|
303
|
+
)
|
|
304
|
+
:handled
|
|
305
|
+
end
|
|
280
306
|
end
|
|
281
307
|
# rubocop:enable Metrics/ModuleLength
|
|
282
308
|
end
|
|
@@ -29,7 +29,8 @@ module Legion
|
|
|
29
29
|
/hotkeys /save /load /sessions /system /delete /plan /palette /extensions /config
|
|
30
30
|
/theme /search /grep /stats /personality /undo /history /pin /pins /rename
|
|
31
31
|
/context /alias /snippet /debug /uptime /time /bookmark /welcome /tips
|
|
32
|
-
/wc /import /mute /autosave /react /macro /tag /tags
|
|
32
|
+
/wc /import /mute /autosave /react /macro /tag /tags /repeat /count
|
|
33
|
+
/template /fav /favs /log /version].freeze
|
|
33
34
|
|
|
34
35
|
PERSONALITIES = {
|
|
35
36
|
'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
|
|
@@ -66,6 +67,7 @@ module Legion
|
|
|
66
67
|
@last_autosave = Time.now
|
|
67
68
|
@recording_macro = nil
|
|
68
69
|
@macro_buffer = []
|
|
70
|
+
@last_command = nil
|
|
69
71
|
end
|
|
70
72
|
|
|
71
73
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -123,6 +125,7 @@ module Legion
|
|
|
123
125
|
end
|
|
124
126
|
|
|
125
127
|
result = dispatch_slash(cmd, input)
|
|
128
|
+
@last_command = input if cmd != '/repeat'
|
|
126
129
|
record_macro_step(input, cmd, result)
|
|
127
130
|
result
|
|
128
131
|
end
|
|
@@ -375,11 +378,27 @@ module Legion
|
|
|
375
378
|
when '/macro' then handle_macro(input)
|
|
376
379
|
when '/tag' then handle_tag(input)
|
|
377
380
|
when '/tags' then handle_tags(input)
|
|
381
|
+
when '/repeat' then handle_repeat
|
|
382
|
+
when '/count' then handle_count(input)
|
|
383
|
+
when '/template' then handle_template(input)
|
|
384
|
+
when '/fav' then handle_fav(input)
|
|
385
|
+
when '/favs' then handle_favs
|
|
386
|
+
when '/log' then handle_log(input)
|
|
387
|
+
when '/version' then handle_version
|
|
378
388
|
else :handled
|
|
379
389
|
end
|
|
380
390
|
end
|
|
381
391
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
382
392
|
|
|
393
|
+
def handle_repeat
|
|
394
|
+
unless @last_command
|
|
395
|
+
@message_stream.add_message(role: :system, content: 'No previous command to repeat.')
|
|
396
|
+
return :handled
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
dispatch_slash(@last_command.split.first, @last_command)
|
|
400
|
+
end
|
|
401
|
+
|
|
383
402
|
def handle_cost
|
|
384
403
|
@message_stream.add_message(role: :system, content: @token_tracker.summary)
|
|
385
404
|
:handled
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fileutils'
|
|
3
4
|
require 'json'
|
|
4
5
|
require_relative 'base'
|
|
5
6
|
require_relative '../theme'
|
|
@@ -41,7 +42,8 @@ module Legion
|
|
|
41
42
|
else
|
|
42
43
|
file_list_lines(height - 4)
|
|
43
44
|
end
|
|
44
|
-
|
|
45
|
+
hint = @viewing_file ? ' Enter=edit b=backup q=back' : ' Enter=view e=edit q=back'
|
|
46
|
+
lines += ['', Theme.c(:muted, hint)]
|
|
45
47
|
pad_lines(lines, height)
|
|
46
48
|
end
|
|
47
49
|
|
|
@@ -79,6 +81,9 @@ module Legion
|
|
|
79
81
|
:handled
|
|
80
82
|
when 'e', :enter then edit_selected_key
|
|
81
83
|
:handled
|
|
84
|
+
when 'b'
|
|
85
|
+
backup_current_file
|
|
86
|
+
:handled
|
|
82
87
|
when 'q', :escape
|
|
83
88
|
@viewing_file = false
|
|
84
89
|
@selected_key = 0
|
|
@@ -130,10 +135,25 @@ module Legion
|
|
|
130
135
|
false
|
|
131
136
|
end
|
|
132
137
|
|
|
138
|
+
def backup_config(path)
|
|
139
|
+
return unless File.exist?(path)
|
|
140
|
+
|
|
141
|
+
FileUtils.cp(path, "#{path}.bak")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def backup_current_file
|
|
145
|
+
return unless @files[@selected_file]
|
|
146
|
+
|
|
147
|
+
path = @files[@selected_file][:path]
|
|
148
|
+
backup_config(path)
|
|
149
|
+
@backup_notice = "Backed up to #{File.basename(path)}.bak"
|
|
150
|
+
end
|
|
151
|
+
|
|
133
152
|
def save_current_file
|
|
134
153
|
return unless @files[@selected_file]
|
|
135
154
|
|
|
136
155
|
path = @files[@selected_file][:path]
|
|
156
|
+
backup_config(path)
|
|
137
157
|
File.write(path, ::JSON.pretty_generate(@file_data))
|
|
138
158
|
end
|
|
139
159
|
|
|
@@ -17,12 +17,15 @@ module Legion
|
|
|
17
17
|
SERVICE = %w[lex-http lex-vault lex-github lex-consul lex-kerberos lex-tfe
|
|
18
18
|
lex-redis lex-memcached lex-elasticsearch lex-s3].freeze
|
|
19
19
|
|
|
20
|
+
CATEGORIES = [nil, 'Core', 'AI', 'Service', 'Agentic', 'Other'].freeze
|
|
21
|
+
|
|
20
22
|
def initialize(app, output: $stdout)
|
|
21
23
|
super(app)
|
|
22
24
|
@output = output
|
|
23
25
|
@gems = []
|
|
24
26
|
@selected = 0
|
|
25
27
|
@detail = false
|
|
28
|
+
@filter = nil
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
def activate
|
|
@@ -36,45 +39,64 @@ module Legion
|
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
def render(_width, height)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
filter_label = @filter ? Theme.c(:warning, " filter: #{@filter}") : ''
|
|
43
|
+
header = [Theme.c(:accent, ' LEX Extensions'), filter_label].reject(&:empty?)
|
|
44
|
+
lines = header + ['']
|
|
45
|
+
lines += if @detail && current_gems[@selected]
|
|
46
|
+
detail_lines(current_gems[@selected])
|
|
42
47
|
else
|
|
43
48
|
list_lines(height - 4)
|
|
44
49
|
end
|
|
45
|
-
lines += ['', Theme.c(:muted, ' Enter=detail o=open q=back')]
|
|
50
|
+
lines += ['', Theme.c(:muted, ' Enter=detail o=open f=filter c=clear q=back')]
|
|
46
51
|
pad_lines(lines, height)
|
|
47
52
|
end
|
|
48
53
|
|
|
49
|
-
# rubocop:disable Metrics/MethodLength
|
|
50
54
|
def handle_input(key)
|
|
51
55
|
case key
|
|
52
56
|
when :up
|
|
53
57
|
@selected = [(@selected - 1), 0].max
|
|
54
58
|
:handled
|
|
55
59
|
when :down
|
|
56
|
-
|
|
60
|
+
max = [current_gems.size - 1, 0].max
|
|
61
|
+
@selected = [(@selected + 1), max].min
|
|
57
62
|
:handled
|
|
58
63
|
when :enter
|
|
59
64
|
@detail = !@detail
|
|
60
65
|
:handled
|
|
66
|
+
when 'q', :escape
|
|
67
|
+
handle_back_key
|
|
68
|
+
else
|
|
69
|
+
handle_action_key(key)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def handle_back_key
|
|
76
|
+
if @detail
|
|
77
|
+
@detail = false
|
|
78
|
+
:handled
|
|
79
|
+
else
|
|
80
|
+
:pop_screen
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def handle_action_key(key)
|
|
85
|
+
case key
|
|
61
86
|
when 'o'
|
|
62
87
|
open_homepage
|
|
63
88
|
:handled
|
|
64
|
-
when '
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
when 'f'
|
|
90
|
+
cycle_filter
|
|
91
|
+
:handled
|
|
92
|
+
when 'c'
|
|
93
|
+
@filter = nil
|
|
94
|
+
@selected = 0
|
|
95
|
+
:handled
|
|
71
96
|
else
|
|
72
97
|
:pass
|
|
73
98
|
end
|
|
74
99
|
end
|
|
75
|
-
# rubocop:enable Metrics/MethodLength
|
|
76
|
-
|
|
77
|
-
private
|
|
78
100
|
|
|
79
101
|
def build_entry(spec)
|
|
80
102
|
loaded = $LOADED_FEATURES.any? { |f| f.include?(spec.name.tr('-', '/')) }
|
|
@@ -100,7 +122,7 @@ module Legion
|
|
|
100
122
|
|
|
101
123
|
# rubocop:disable Metrics/AbcSize
|
|
102
124
|
def list_lines(max_height)
|
|
103
|
-
grouped =
|
|
125
|
+
grouped = current_gems.group_by { |g| g[:category] }
|
|
104
126
|
lines = []
|
|
105
127
|
idx = 0
|
|
106
128
|
grouped.each do |cat, gems|
|
|
@@ -132,6 +154,18 @@ module Legion
|
|
|
132
154
|
]
|
|
133
155
|
end
|
|
134
156
|
|
|
157
|
+
def cycle_filter
|
|
158
|
+
idx = CATEGORIES.index(@filter) || 0
|
|
159
|
+
@filter = CATEGORIES[(idx + 1) % CATEGORIES.size]
|
|
160
|
+
@selected = 0
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def current_gems
|
|
164
|
+
return @gems unless @filter
|
|
165
|
+
|
|
166
|
+
@gems.select { |g| g[:category] == @filter }
|
|
167
|
+
end
|
|
168
|
+
|
|
135
169
|
def open_homepage
|
|
136
170
|
entry = current_gem
|
|
137
171
|
return unless entry && entry[:homepage]
|
|
@@ -150,9 +184,10 @@ module Legion
|
|
|
150
184
|
end
|
|
151
185
|
|
|
152
186
|
def current_gem
|
|
153
|
-
|
|
187
|
+
gems = current_gems
|
|
188
|
+
return nil if gems.empty?
|
|
154
189
|
|
|
155
|
-
|
|
190
|
+
gems[@selected]
|
|
156
191
|
end
|
|
157
192
|
|
|
158
193
|
def pad_lines(lines, height)
|
data/lib/legion/tty/version.rb
CHANGED