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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22a1a4b5a0dcae6da430188229f80fc7dfbbdbbc75c7b58516d93d7b35ee60cc
4
- data.tar.gz: 309f256644b595a3f1aa15501482d40b5c27b26aa18707fd237207c479f14946
3
+ metadata.gz: 764baccaffa8d9863f8cb85248c15775fce561f729b74d70ff0b597f343eb0f5
4
+ data.tar.gz: 39cacbd736ae3aacbeb54c429428c55cbde2a9325654f6d32e3e272c60586f7e
5
5
  SHA512:
6
- metadata.gz: d2fe62fbe7805f0874f1988c7b1d226c00304bcfd86c84011b6e5a95707e684170d91966d1947360ed3ce021328ed3e57f445bb2b85cc5463891225b58f796a0
7
- data.tar.gz: a6cf5433e3a80fa06f72003ae079d8cdf3e1b30cc1adaa64555c78becb3fc1a530889c9aacf3c031e988ff55c28ef8463e37e89196d713d6b9c87e238698871c
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].freeze
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
- lines += ['', Theme.c(:muted, ' Enter=view e=edit q=back')]
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
- lines = [Theme.c(:accent, ' LEX Extensions'), '']
40
- lines += if @detail && @gems[@selected]
41
- detail_lines(@gems[@selected])
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
- @selected = [(@selected + 1), @gems.size - 1].min
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 'q', :escape
65
- if @detail
66
- @detail = false
67
- :handled
68
- else
69
- :pop_screen
70
- end
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 = @gems.group_by { |g| g[:category] }
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
- return nil if @gems.empty?
187
+ gems = current_gems
188
+ return nil if gems.empty?
154
189
 
155
- @gems[@selected]
190
+ gems[@selected]
156
191
  end
157
192
 
158
193
  def pad_lines(lines, height)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.15'
5
+ VERSION = '0.4.17'
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.15
4
+ version: 0.4.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity