legion-tty 0.4.14 → 0.4.15
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 +8 -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 +92 -0
- data/lib/legion/tty/screens/chat/session_commands.rb +33 -0
- data/lib/legion/tty/screens/chat.rb +29 -5
- 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: 22a1a4b5a0dcae6da430188229f80fc7dfbbdbbc75c7b58516d93d7b35ee60cc
|
|
4
|
+
data.tar.gz: 309f256644b595a3f1aa15501482d40b5c27b26aa18707fd237207c479f14946
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2fe62fbe7805f0874f1988c7b1d226c00304bcfd86c84011b6e5a95707e684170d91966d1947360ed3ce021328ed3e57f445bb2b85cc5463891225b58f796a0
|
|
7
|
+
data.tar.gz: a6cf5433e3a80fa06f72003ae079d8cdf3e1b30cc1adaa64555c78becb3fc1a530889c9aacf3c031e988ff55c28ef8463e37e89196d713d6b9c87e238698871c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.15] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `/autosave [N|off]` command: toggle periodic auto-save with configurable interval (default 60s)
|
|
7
|
+
- `/react <emoji>` command: add emoji reactions to messages (displayed in render)
|
|
8
|
+
- `/macro record|stop|play|list|delete` command: record and replay slash command sequences
|
|
9
|
+
- `/tag` and `/tags` commands: tag messages with labels, filter by tag, show tag statistics
|
|
10
|
+
|
|
3
11
|
## [0.4.14] - 2026-03-19
|
|
4
12
|
|
|
5
13
|
### 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,70 @@ 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
|
+
|
|
165
229
|
def search_messages(query)
|
|
166
230
|
pattern = query.downcase
|
|
167
231
|
@message_stream.messages.select do |msg|
|
|
@@ -184,6 +248,34 @@ module Legion
|
|
|
184
248
|
nil
|
|
185
249
|
end
|
|
186
250
|
end
|
|
251
|
+
|
|
252
|
+
# rubocop:disable Metrics/AbcSize
|
|
253
|
+
def show_all_tags
|
|
254
|
+
tagged = @message_stream.messages.select { |m| m[:tags]&.any? }
|
|
255
|
+
if tagged.empty?
|
|
256
|
+
@message_stream.add_message(role: :system, content: 'No tagged messages.')
|
|
257
|
+
return
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
counts = Hash.new(0)
|
|
261
|
+
tagged.each { |m| m[:tags].each { |t| counts[t] += 1 } }
|
|
262
|
+
lines = counts.sort.map { |tag, count| " ##{tag} (#{count})" }
|
|
263
|
+
@message_stream.add_message(role: :system, content: "Tags:\n#{lines.join("\n")}")
|
|
264
|
+
end
|
|
265
|
+
# rubocop:enable Metrics/AbcSize
|
|
266
|
+
|
|
267
|
+
def filter_messages_by_tag(label)
|
|
268
|
+
results = @message_stream.messages.select { |m| m[:tags]&.include?(label) }
|
|
269
|
+
if results.empty?
|
|
270
|
+
@message_stream.add_message(role: :system, content: "No messages tagged '##{label}'.")
|
|
271
|
+
else
|
|
272
|
+
lines = results.map { |r| " [#{r[:role]}] #{truncate_text(r[:content].to_s, 80)}" }
|
|
273
|
+
@message_stream.add_message(
|
|
274
|
+
role: :system,
|
|
275
|
+
content: "Messages tagged '##{label}' (#{results.size}):\n#{lines.join("\n")}"
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
187
279
|
end
|
|
188
280
|
# rubocop:enable Metrics/ModuleLength
|
|
189
281
|
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].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,18 @@ 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 = []
|
|
63
69
|
end
|
|
64
70
|
|
|
65
|
-
# rubocop:enable Metrics/AbcSize
|
|
71
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
66
72
|
|
|
67
73
|
def activate
|
|
68
74
|
@running = true
|
|
@@ -116,7 +122,9 @@ module Legion
|
|
|
116
122
|
return handle_slash_command("#{expanded} #{input.split(nil, 2)[1]}".strip)
|
|
117
123
|
end
|
|
118
124
|
|
|
119
|
-
dispatch_slash(cmd, input)
|
|
125
|
+
result = dispatch_slash(cmd, input)
|
|
126
|
+
record_macro_step(input, cmd, result)
|
|
127
|
+
result
|
|
120
128
|
end
|
|
121
129
|
|
|
122
130
|
def handle_user_message(input)
|
|
@@ -128,6 +136,7 @@ module Legion
|
|
|
128
136
|
send_to_llm(input)
|
|
129
137
|
end
|
|
130
138
|
@status_bar.update(message_count: @message_stream.messages.size)
|
|
139
|
+
check_autosave
|
|
131
140
|
render_screen
|
|
132
141
|
end
|
|
133
142
|
|
|
@@ -175,6 +184,14 @@ module Legion
|
|
|
175
184
|
|
|
176
185
|
private
|
|
177
186
|
|
|
187
|
+
def record_macro_step(input, cmd, result)
|
|
188
|
+
return unless @recording_macro
|
|
189
|
+
return if cmd == '/macro'
|
|
190
|
+
return unless result == :handled
|
|
191
|
+
|
|
192
|
+
@macro_buffer << input
|
|
193
|
+
end
|
|
194
|
+
|
|
178
195
|
def setup_system_prompt
|
|
179
196
|
cfg = safe_config
|
|
180
197
|
return unless @llm_chat && cfg.is_a?(Hash) && !cfg.empty?
|
|
@@ -353,6 +370,11 @@ module Legion
|
|
|
353
370
|
when '/wc' then handle_wc
|
|
354
371
|
when '/import' then handle_import(input)
|
|
355
372
|
when '/mute' then handle_mute
|
|
373
|
+
when '/autosave' then handle_autosave(input)
|
|
374
|
+
when '/react' then handle_react(input)
|
|
375
|
+
when '/macro' then handle_macro(input)
|
|
376
|
+
when '/tag' then handle_tag(input)
|
|
377
|
+
when '/tags' then handle_tags(input)
|
|
356
378
|
else :handled
|
|
357
379
|
end
|
|
358
380
|
end
|
|
@@ -431,7 +453,9 @@ module Legion
|
|
|
431
453
|
"personality:#{@personality || 'default'} " \
|
|
432
454
|
"aliases:#{@aliases.size} " \
|
|
433
455
|
"snippets:#{@snippets.size} " \
|
|
434
|
-
"
|
|
456
|
+
"macros:#{@macros.size} " \
|
|
457
|
+
"pinned:#{@pinned_messages.size} " \
|
|
458
|
+
"autosave:#{@autosave_enabled}"
|
|
435
459
|
end
|
|
436
460
|
|
|
437
461
|
def build_default_input_bar
|
data/lib/legion/tty/version.rb
CHANGED