legion-tty 0.4.14 → 0.4.16
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/components/message_stream.rb +11 -2
- data/lib/legion/tty/screens/chat/custom_commands.rb +99 -0
- data/lib/legion/tty/screens/chat/message_commands.rb +114 -0
- data/lib/legion/tty/screens/chat/session_commands.rb +33 -0
- data/lib/legion/tty/screens/chat.rb +42 -5
- 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: 70299176c70666c5c4a4e7a1e6ac8ae1a03050525e299b7fcc4cf6a4cb8146c4
|
|
4
|
+
data.tar.gz: c3cf40f5acfa8e14da112380c93931340985d937aafd6667c638bdb4b3ed0913
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8df88d8c600985633a63d34c18c3d1366dd9868d4109c2240c7522fa905511d04d865a8a259af71009840571ec627dae22f0def660043ecbc63701a4a0564dcb
|
|
7
|
+
data.tar.gz: 7b607c3304efb10d94866d23eabd122b1aee84e0cc6cb8ecd7329129ad6b18277867b4e74825788d0bbc171563ea6a60c73d75fd2bdeeb7be1e9227ec26515a0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.16] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Extensions screen category filter: 'f' cycles through Core/AI/Service/Agentic/Other, 'c' clears
|
|
7
|
+
- Config screen backup: 'b' creates .bak copy, auto-backup before edits
|
|
8
|
+
- `/repeat` command: re-execute the last slash command
|
|
9
|
+
- `/count <pattern>` command: count messages matching a pattern with per-role breakdown
|
|
10
|
+
|
|
11
|
+
## [0.4.15] - 2026-03-19
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `/autosave [N|off]` command: toggle periodic auto-save with configurable interval (default 60s)
|
|
15
|
+
- `/react <emoji>` command: add emoji reactions to messages (displayed in render)
|
|
16
|
+
- `/macro record|stop|play|list|delete` command: record and replay slash command sequences
|
|
17
|
+
- `/tag` and `/tags` commands: tag messages with labels, filter by tag, show tag statistics
|
|
18
|
+
|
|
3
19
|
## [0.4.14] - 2026-03-19
|
|
4
20
|
|
|
5
21
|
### Added
|
|
@@ -96,7 +96,9 @@ module Legion
|
|
|
96
96
|
def user_lines(msg, _width)
|
|
97
97
|
ts = format_timestamp(msg[:timestamp])
|
|
98
98
|
header = "#{Theme.c(:accent, 'You')} #{Theme.c(:muted, ts)}"
|
|
99
|
-
['', "#{header}: #{msg[:content]}"]
|
|
99
|
+
lines = ['', "#{header}: #{msg[:content]}"]
|
|
100
|
+
lines << reaction_line(msg) if msg[:reactions]&.any?
|
|
101
|
+
lines
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
def format_timestamp(time)
|
|
@@ -107,7 +109,14 @@ module Legion
|
|
|
107
109
|
|
|
108
110
|
def assistant_lines(msg, width)
|
|
109
111
|
rendered = render_markdown(msg[:content], width)
|
|
110
|
-
['', *rendered.split("\n")]
|
|
112
|
+
lines = ['', *rendered.split("\n")]
|
|
113
|
+
lines << reaction_line(msg) if msg[:reactions]&.any?
|
|
114
|
+
lines
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def reaction_line(msg)
|
|
118
|
+
reactions = msg[:reactions].map { |r| "[#{r}]" }.join(' ')
|
|
119
|
+
" #{Theme.c(:muted, reactions)}"
|
|
111
120
|
end
|
|
112
121
|
|
|
113
122
|
def render_markdown(text, width)
|
|
@@ -125,6 +125,105 @@ module Legion
|
|
|
125
125
|
end
|
|
126
126
|
# rubocop:enable Metrics/AbcSize
|
|
127
127
|
|
|
128
|
+
# rubocop:disable Metrics/MethodLength
|
|
129
|
+
def handle_macro(input)
|
|
130
|
+
parts = input.split(nil, 3)
|
|
131
|
+
subcommand = parts[1]
|
|
132
|
+
name = parts[2]
|
|
133
|
+
|
|
134
|
+
case subcommand
|
|
135
|
+
when 'record'
|
|
136
|
+
macro_record(name)
|
|
137
|
+
when 'stop'
|
|
138
|
+
macro_stop
|
|
139
|
+
when 'play'
|
|
140
|
+
macro_play(name)
|
|
141
|
+
when 'list'
|
|
142
|
+
macro_list
|
|
143
|
+
when 'delete'
|
|
144
|
+
macro_delete(name)
|
|
145
|
+
else
|
|
146
|
+
@message_stream.add_message(
|
|
147
|
+
role: :system,
|
|
148
|
+
content: 'Usage: /macro record|stop|play|list|delete <name>'
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
:handled
|
|
152
|
+
end
|
|
153
|
+
# rubocop:enable Metrics/MethodLength
|
|
154
|
+
|
|
155
|
+
def macro_record(name)
|
|
156
|
+
unless name
|
|
157
|
+
@message_stream.add_message(role: :system, content: 'Usage: /macro record <name>')
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
@recording_macro = name
|
|
162
|
+
@macro_buffer = []
|
|
163
|
+
@message_stream.add_message(role: :system,
|
|
164
|
+
content: "Recording macro '#{name}'... Use /macro stop to finish.")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def macro_stop
|
|
168
|
+
unless @recording_macro
|
|
169
|
+
@message_stream.add_message(role: :system, content: 'No macro recording in progress.')
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
name = @recording_macro
|
|
174
|
+
@macros[name] = @macro_buffer.dup
|
|
175
|
+
@recording_macro = nil
|
|
176
|
+
@macro_buffer = []
|
|
177
|
+
@message_stream.add_message(role: :system,
|
|
178
|
+
content: "Macro '#{name}' saved (#{@macros[name].size} commands).")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def macro_play(name)
|
|
182
|
+
unless name
|
|
183
|
+
@message_stream.add_message(role: :system, content: 'Usage: /macro play <name>')
|
|
184
|
+
return
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
commands = @macros[name]
|
|
188
|
+
unless commands
|
|
189
|
+
@message_stream.add_message(role: :system, content: "Macro '#{name}' not found.")
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
@message_stream.add_message(role: :system,
|
|
194
|
+
content: "Playing macro '#{name}' (#{commands.size} commands)...")
|
|
195
|
+
commands.each { |cmd| handle_slash_command(cmd) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def macro_list
|
|
199
|
+
if @macros.empty?
|
|
200
|
+
@message_stream.add_message(role: :system, content: 'No macros defined.')
|
|
201
|
+
return
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
lines = @macros.map do |n, cmds|
|
|
205
|
+
preview = cmds.first(3).join(', ')
|
|
206
|
+
preview += ', ...' if cmds.size > 3
|
|
207
|
+
" #{n} (#{cmds.size}): #{preview}"
|
|
208
|
+
end
|
|
209
|
+
status = @recording_macro ? " [recording: #{@recording_macro}]" : ''
|
|
210
|
+
@message_stream.add_message(role: :system,
|
|
211
|
+
content: "Macros (#{@macros.size})#{status}:\n#{lines.join("\n")}")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def macro_delete(name)
|
|
215
|
+
unless name
|
|
216
|
+
@message_stream.add_message(role: :system, content: 'Usage: /macro delete <name>')
|
|
217
|
+
return
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
if @macros.delete(name)
|
|
221
|
+
@message_stream.add_message(role: :system, content: "Macro '#{name}' deleted.")
|
|
222
|
+
else
|
|
223
|
+
@message_stream.add_message(role: :system, content: "Macro '#{name}' not found.")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
128
227
|
def snippet_delete(name)
|
|
129
228
|
unless name
|
|
130
229
|
@message_stream.add_message(role: :system, content: 'Usage: /snippet delete <name>')
|
|
@@ -162,6 +162,92 @@ module Legion
|
|
|
162
162
|
:handled
|
|
163
163
|
end
|
|
164
164
|
|
|
165
|
+
# rubocop:disable Metrics/AbcSize
|
|
166
|
+
def handle_react(input)
|
|
167
|
+
parts = input.split(nil, 3)
|
|
168
|
+
if parts.size == 2
|
|
169
|
+
emoji = parts[1]
|
|
170
|
+
msg = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
|
|
171
|
+
elsif parts.size >= 3 && parts[1].match?(/\A\d+\z/)
|
|
172
|
+
idx = parts[1].to_i
|
|
173
|
+
emoji = parts[2]
|
|
174
|
+
msg = @message_stream.messages[idx]
|
|
175
|
+
else
|
|
176
|
+
@message_stream.add_message(role: :system, content: 'Usage: /react <emoji> or /react <N> <emoji>')
|
|
177
|
+
return :handled
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
unless msg
|
|
181
|
+
@message_stream.add_message(role: :system, content: 'No message to react to.')
|
|
182
|
+
return :handled
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
msg[:reactions] ||= []
|
|
186
|
+
msg[:reactions] << emoji
|
|
187
|
+
@message_stream.add_message(role: :system, content: "Reaction #{emoji} added.")
|
|
188
|
+
:handled
|
|
189
|
+
end
|
|
190
|
+
# rubocop:enable Metrics/AbcSize
|
|
191
|
+
|
|
192
|
+
# rubocop:disable Metrics/AbcSize
|
|
193
|
+
def handle_tag(input)
|
|
194
|
+
parts = input.split(nil, 3)
|
|
195
|
+
if parts.size == 2
|
|
196
|
+
label = parts[1]
|
|
197
|
+
msg = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
|
|
198
|
+
elsif parts.size >= 3 && parts[1].match?(/\A\d+\z/)
|
|
199
|
+
idx = parts[1].to_i
|
|
200
|
+
label = parts[2]
|
|
201
|
+
msg = @message_stream.messages[idx]
|
|
202
|
+
else
|
|
203
|
+
@message_stream.add_message(role: :system, content: 'Usage: /tag <label> or /tag <N> <label>')
|
|
204
|
+
return :handled
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
unless msg
|
|
208
|
+
@message_stream.add_message(role: :system, content: 'No message to tag.')
|
|
209
|
+
return :handled
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
msg[:tags] ||= []
|
|
213
|
+
msg[:tags] |= [label]
|
|
214
|
+
@message_stream.add_message(role: :system, content: "Tag '#{label}' added.")
|
|
215
|
+
:handled
|
|
216
|
+
end
|
|
217
|
+
# rubocop:enable Metrics/AbcSize
|
|
218
|
+
|
|
219
|
+
def handle_tags(input)
|
|
220
|
+
label = input.split(nil, 2)[1]
|
|
221
|
+
if label
|
|
222
|
+
filter_messages_by_tag(label)
|
|
223
|
+
else
|
|
224
|
+
show_all_tags
|
|
225
|
+
end
|
|
226
|
+
:handled
|
|
227
|
+
end
|
|
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
|
+
|
|
165
251
|
def search_messages(query)
|
|
166
252
|
pattern = query.downcase
|
|
167
253
|
@message_stream.messages.select do |msg|
|
|
@@ -184,6 +270,34 @@ module Legion
|
|
|
184
270
|
nil
|
|
185
271
|
end
|
|
186
272
|
end
|
|
273
|
+
|
|
274
|
+
# rubocop:disable Metrics/AbcSize
|
|
275
|
+
def show_all_tags
|
|
276
|
+
tagged = @message_stream.messages.select { |m| m[:tags]&.any? }
|
|
277
|
+
if tagged.empty?
|
|
278
|
+
@message_stream.add_message(role: :system, content: 'No tagged messages.')
|
|
279
|
+
return
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
counts = Hash.new(0)
|
|
283
|
+
tagged.each { |m| m[:tags].each { |t| counts[t] += 1 } }
|
|
284
|
+
lines = counts.sort.map { |tag, count| " ##{tag} (#{count})" }
|
|
285
|
+
@message_stream.add_message(role: :system, content: "Tags:\n#{lines.join("\n")}")
|
|
286
|
+
end
|
|
287
|
+
# rubocop:enable Metrics/AbcSize
|
|
288
|
+
|
|
289
|
+
def filter_messages_by_tag(label)
|
|
290
|
+
results = @message_stream.messages.select { |m| m[:tags]&.include?(label) }
|
|
291
|
+
if results.empty?
|
|
292
|
+
@message_stream.add_message(role: :system, content: "No messages tagged '##{label}'.")
|
|
293
|
+
else
|
|
294
|
+
lines = results.map { |r| " [#{r[:role]}] #{truncate_text(r[:content].to_s, 80)}" }
|
|
295
|
+
@message_stream.add_message(
|
|
296
|
+
role: :system,
|
|
297
|
+
content: "Messages tagged '##{label}' (#{results.size}):\n#{lines.join("\n")}"
|
|
298
|
+
)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
187
301
|
end
|
|
188
302
|
# rubocop:enable Metrics/ModuleLength
|
|
189
303
|
end
|
|
@@ -113,6 +113,39 @@ module Legion
|
|
|
113
113
|
:handled
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
+
def handle_autosave(input)
|
|
117
|
+
arg = input.split(nil, 2)[1]
|
|
118
|
+
if arg.nil?
|
|
119
|
+
@autosave_enabled = !@autosave_enabled
|
|
120
|
+
status = @autosave_enabled ? "ON (every #{@autosave_interval}s)" : 'OFF'
|
|
121
|
+
@status_bar.notify(message: "Autosave: #{status}", level: :info, ttl: 3)
|
|
122
|
+
@message_stream.add_message(role: :system, content: "Autosave #{status}.")
|
|
123
|
+
elsif arg == 'off'
|
|
124
|
+
@autosave_enabled = false
|
|
125
|
+
@status_bar.notify(message: 'Autosave: OFF', level: :info, ttl: 3)
|
|
126
|
+
@message_stream.add_message(role: :system, content: 'Autosave OFF.')
|
|
127
|
+
elsif arg.match?(/\A\d+\z/)
|
|
128
|
+
@autosave_interval = arg.to_i
|
|
129
|
+
@autosave_enabled = true
|
|
130
|
+
@status_bar.notify(message: "Autosave: ON (every #{@autosave_interval}s)", level: :info, ttl: 3)
|
|
131
|
+
@message_stream.add_message(role: :system, content: "Autosave ON (every #{@autosave_interval}s).")
|
|
132
|
+
else
|
|
133
|
+
@message_stream.add_message(role: :system, content: 'Usage: /autosave [off|<seconds>]')
|
|
134
|
+
end
|
|
135
|
+
:handled
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def check_autosave
|
|
139
|
+
return unless @autosave_enabled
|
|
140
|
+
return unless Time.now - @last_autosave >= @autosave_interval
|
|
141
|
+
|
|
142
|
+
auto_save_session
|
|
143
|
+
@last_autosave = Time.now
|
|
144
|
+
@status_bar.notify(message: 'Autosaved', level: :info, ttl: 2)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
116
149
|
def auto_save_session
|
|
117
150
|
return if @message_stream.messages.empty?
|
|
118
151
|
|
|
@@ -29,7 +29,7 @@ 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].freeze
|
|
32
|
+
/wc /import /mute /autosave /react /macro /tag /tags /repeat /count].freeze
|
|
33
33
|
|
|
34
34
|
PERSONALITIES = {
|
|
35
35
|
'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
|
|
@@ -41,7 +41,7 @@ module Legion
|
|
|
41
41
|
|
|
42
42
|
attr_reader :message_stream, :status_bar
|
|
43
43
|
|
|
44
|
-
# rubocop:disable Metrics/AbcSize
|
|
44
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
45
45
|
def initialize(app, output: $stdout, input_bar: nil)
|
|
46
46
|
super(app)
|
|
47
47
|
@output = output
|
|
@@ -57,12 +57,19 @@ module Legion
|
|
|
57
57
|
@pinned_messages = []
|
|
58
58
|
@aliases = {}
|
|
59
59
|
@snippets = {}
|
|
60
|
+
@macros = {}
|
|
60
61
|
@debug_mode = false
|
|
61
62
|
@session_start = Time.now
|
|
62
63
|
@muted_system = false
|
|
64
|
+
@autosave_enabled = false
|
|
65
|
+
@autosave_interval = 60
|
|
66
|
+
@last_autosave = Time.now
|
|
67
|
+
@recording_macro = nil
|
|
68
|
+
@macro_buffer = []
|
|
69
|
+
@last_command = nil
|
|
63
70
|
end
|
|
64
71
|
|
|
65
|
-
# rubocop:enable Metrics/AbcSize
|
|
72
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
66
73
|
|
|
67
74
|
def activate
|
|
68
75
|
@running = true
|
|
@@ -116,7 +123,10 @@ module Legion
|
|
|
116
123
|
return handle_slash_command("#{expanded} #{input.split(nil, 2)[1]}".strip)
|
|
117
124
|
end
|
|
118
125
|
|
|
119
|
-
dispatch_slash(cmd, input)
|
|
126
|
+
result = dispatch_slash(cmd, input)
|
|
127
|
+
@last_command = input if cmd != '/repeat'
|
|
128
|
+
record_macro_step(input, cmd, result)
|
|
129
|
+
result
|
|
120
130
|
end
|
|
121
131
|
|
|
122
132
|
def handle_user_message(input)
|
|
@@ -128,6 +138,7 @@ module Legion
|
|
|
128
138
|
send_to_llm(input)
|
|
129
139
|
end
|
|
130
140
|
@status_bar.update(message_count: @message_stream.messages.size)
|
|
141
|
+
check_autosave
|
|
131
142
|
render_screen
|
|
132
143
|
end
|
|
133
144
|
|
|
@@ -175,6 +186,14 @@ module Legion
|
|
|
175
186
|
|
|
176
187
|
private
|
|
177
188
|
|
|
189
|
+
def record_macro_step(input, cmd, result)
|
|
190
|
+
return unless @recording_macro
|
|
191
|
+
return if cmd == '/macro'
|
|
192
|
+
return unless result == :handled
|
|
193
|
+
|
|
194
|
+
@macro_buffer << input
|
|
195
|
+
end
|
|
196
|
+
|
|
178
197
|
def setup_system_prompt
|
|
179
198
|
cfg = safe_config
|
|
180
199
|
return unless @llm_chat && cfg.is_a?(Hash) && !cfg.empty?
|
|
@@ -353,11 +372,27 @@ module Legion
|
|
|
353
372
|
when '/wc' then handle_wc
|
|
354
373
|
when '/import' then handle_import(input)
|
|
355
374
|
when '/mute' then handle_mute
|
|
375
|
+
when '/autosave' then handle_autosave(input)
|
|
376
|
+
when '/react' then handle_react(input)
|
|
377
|
+
when '/macro' then handle_macro(input)
|
|
378
|
+
when '/tag' then handle_tag(input)
|
|
379
|
+
when '/tags' then handle_tags(input)
|
|
380
|
+
when '/repeat' then handle_repeat
|
|
381
|
+
when '/count' then handle_count(input)
|
|
356
382
|
else :handled
|
|
357
383
|
end
|
|
358
384
|
end
|
|
359
385
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
360
386
|
|
|
387
|
+
def handle_repeat
|
|
388
|
+
unless @last_command
|
|
389
|
+
@message_stream.add_message(role: :system, content: 'No previous command to repeat.')
|
|
390
|
+
return :handled
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
dispatch_slash(@last_command.split.first, @last_command)
|
|
394
|
+
end
|
|
395
|
+
|
|
361
396
|
def handle_cost
|
|
362
397
|
@message_stream.add_message(role: :system, content: @token_tracker.summary)
|
|
363
398
|
:handled
|
|
@@ -431,7 +466,9 @@ module Legion
|
|
|
431
466
|
"personality:#{@personality || 'default'} " \
|
|
432
467
|
"aliases:#{@aliases.size} " \
|
|
433
468
|
"snippets:#{@snippets.size} " \
|
|
434
|
-
"
|
|
469
|
+
"macros:#{@macros.size} " \
|
|
470
|
+
"pinned:#{@pinned_messages.size} " \
|
|
471
|
+
"autosave:#{@autosave_enabled}"
|
|
435
472
|
end
|
|
436
473
|
|
|
437
474
|
def build_default_input_bar
|
|
@@ -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