legion-tty 0.4.16 → 0.4.18
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 +114 -0
- data/lib/legion/tty/screens/chat/model_commands.rb +18 -0
- data/lib/legion/tty/screens/chat/session_commands.rb +23 -0
- data/lib/legion/tty/screens/chat/ui_commands.rb +36 -0
- data/lib/legion/tty/screens/chat.rb +37 -11
- 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: 6faa2820fd40c949a40eb6e336cf75067e3ab400d9631980fc49fda2f0b5dc29
|
|
4
|
+
data.tar.gz: 35c674132cb7f41f0c2c8d4150afe10c99f4ac30fed392e78477fb074c383d67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3766b9312e3fa871e39cfc1b42eac013c82ab680509f10f6f034474fda23381d1bfa4bc67e9e7a95d1557ca546ae3cd6d209818d4fe814111b33cc35b167f46f
|
|
7
|
+
data.tar.gz: c0644da99c0c8cd8b0002366e2e9f46edf23d99a157a8e5652165e3112442516da2178e16ca91596afdbef04a57fe379a347bd6f5c6883903bae24f599a294dd
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.18] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `/focus` command: toggle minimal UI mode (hides status bar for distraction-free writing)
|
|
7
|
+
- `/retry` command: resend last user message to LLM, replacing previous assistant response
|
|
8
|
+
- `/merge <session>` command: import messages from another saved session into current conversation
|
|
9
|
+
- `/sort [length|role]` command: display messages sorted by character length or grouped by role
|
|
10
|
+
|
|
11
|
+
## [0.4.17] - 2026-03-19
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `/template` command: 8 predefined prompt templates (explain, review, summarize, refactor, test, debug, translate, compare)
|
|
15
|
+
- `/fav` and `/favs` commands: persistent favorites saved to `~/.legionio/favorites.json`
|
|
16
|
+
- `/log [N]` command: view last N lines of boot log (default 20)
|
|
17
|
+
- `/version` command: show legion-tty version, Ruby version, and platform
|
|
18
|
+
|
|
3
19
|
## [0.4.16] - 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
|
|
@@ -298,6 +298,120 @@ module Legion
|
|
|
298
298
|
)
|
|
299
299
|
end
|
|
300
300
|
end
|
|
301
|
+
|
|
302
|
+
def handle_sort(input)
|
|
303
|
+
arg = input.split(nil, 2)[1]
|
|
304
|
+
if arg == 'role'
|
|
305
|
+
sort_by_role
|
|
306
|
+
else
|
|
307
|
+
sort_by_length
|
|
308
|
+
end
|
|
309
|
+
:handled
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def sort_by_length
|
|
313
|
+
msgs = @message_stream.messages
|
|
314
|
+
if msgs.empty?
|
|
315
|
+
@message_stream.add_message(role: :system, content: 'No messages to sort.')
|
|
316
|
+
return
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
sorted = msgs.sort_by { |m| -m[:content].to_s.length }.first(10)
|
|
320
|
+
lines = sorted.map { |m| format_length_line(m) }
|
|
321
|
+
@message_stream.add_message(
|
|
322
|
+
role: :system,
|
|
323
|
+
content: "Messages by length (top #{sorted.size}):\n#{lines.join("\n")}"
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def format_length_line(msg)
|
|
328
|
+
len = msg[:content].to_s.length
|
|
329
|
+
preview = truncate_text(msg[:content].to_s, 60)
|
|
330
|
+
" [#{msg[:role]}] (#{len} chars) #{preview}"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def sort_by_role
|
|
334
|
+
msgs = @message_stream.messages
|
|
335
|
+
if msgs.empty?
|
|
336
|
+
@message_stream.add_message(role: :system, content: 'No messages to sort.')
|
|
337
|
+
return
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
counts = msgs.group_by { |m| m[:role] }
|
|
341
|
+
.transform_values(&:size)
|
|
342
|
+
.sort_by { |_, count| -count }
|
|
343
|
+
lines = counts.map { |role, count| " #{role}: #{count}" }
|
|
344
|
+
@message_stream.add_message(
|
|
345
|
+
role: :system,
|
|
346
|
+
content: "Messages by role:\n#{lines.join("\n")}"
|
|
347
|
+
)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def favorites_file
|
|
351
|
+
File.expand_path('~/.legionio/favorites.json')
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def load_favorites
|
|
355
|
+
return [] unless File.exist?(favorites_file)
|
|
356
|
+
|
|
357
|
+
raw = File.read(favorites_file)
|
|
358
|
+
parsed = ::JSON.parse(raw, symbolize_names: true)
|
|
359
|
+
parsed.is_a?(Array) ? parsed : []
|
|
360
|
+
rescue StandardError
|
|
361
|
+
[]
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def save_favorites(favs)
|
|
365
|
+
require 'fileutils'
|
|
366
|
+
FileUtils.mkdir_p(File.dirname(favorites_file))
|
|
367
|
+
File.write(favorites_file, ::JSON.generate(favs))
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
371
|
+
def handle_fav(input)
|
|
372
|
+
idx_str = input.split(nil, 2)[1]
|
|
373
|
+
msg = if idx_str
|
|
374
|
+
@message_stream.messages[idx_str.to_i]
|
|
375
|
+
else
|
|
376
|
+
@message_stream.messages.reverse.find { |m| m[:role] == :assistant }
|
|
377
|
+
end
|
|
378
|
+
unless msg
|
|
379
|
+
@message_stream.add_message(role: :system, content: 'No message to favorite.')
|
|
380
|
+
return :handled
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
@favorites ||= []
|
|
384
|
+
entry = {
|
|
385
|
+
role: msg[:role],
|
|
386
|
+
content: msg[:content].to_s,
|
|
387
|
+
saved_at: Time.now.iso8601,
|
|
388
|
+
session: @session_name
|
|
389
|
+
}
|
|
390
|
+
@favorites << entry
|
|
391
|
+
save_favorites(load_favorites + [entry])
|
|
392
|
+
preview = truncate_text(msg[:content].to_s, 60)
|
|
393
|
+
@message_stream.add_message(role: :system, content: "Favorited: #{preview}")
|
|
394
|
+
:handled
|
|
395
|
+
end
|
|
396
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
397
|
+
|
|
398
|
+
def handle_favs
|
|
399
|
+
all_favs = load_favorites
|
|
400
|
+
if all_favs.empty?
|
|
401
|
+
@message_stream.add_message(role: :system, content: 'No favorites saved.')
|
|
402
|
+
return :handled
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
lines = all_favs.each_with_index.map do |fav, i|
|
|
406
|
+
saved = fav[:saved_at] || ''
|
|
407
|
+
" #{i + 1}. [#{fav[:role]}] #{truncate_text(fav[:content].to_s, 60)} (#{saved})"
|
|
408
|
+
end
|
|
409
|
+
@message_stream.add_message(
|
|
410
|
+
role: :system,
|
|
411
|
+
content: "Favorites (#{all_favs.size}):\n#{lines.join("\n")}"
|
|
412
|
+
)
|
|
413
|
+
:handled
|
|
414
|
+
end
|
|
301
415
|
end
|
|
302
416
|
# rubocop:enable Metrics/ModuleLength
|
|
303
417
|
end
|
|
@@ -4,6 +4,7 @@ module Legion
|
|
|
4
4
|
module TTY
|
|
5
5
|
module Screens
|
|
6
6
|
class Chat < Base
|
|
7
|
+
# rubocop:disable Metrics/ModuleLength
|
|
7
8
|
module ModelCommands
|
|
8
9
|
private
|
|
9
10
|
|
|
@@ -116,7 +117,24 @@ module Legion
|
|
|
116
117
|
@message_stream.add_message(role: :system, content: "Personality set to: #{name} (no active LLM)")
|
|
117
118
|
end
|
|
118
119
|
end
|
|
120
|
+
|
|
121
|
+
def handle_retry
|
|
122
|
+
unless @last_user_input
|
|
123
|
+
@message_stream.add_message(role: :system, content: 'Nothing to retry.')
|
|
124
|
+
return :handled
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
msgs = @message_stream.messages
|
|
128
|
+
last_assistant_idx = msgs.rindex { |m| m[:role] == :assistant }
|
|
129
|
+
msgs.delete_at(last_assistant_idx) if last_assistant_idx
|
|
130
|
+
|
|
131
|
+
@status_bar.notify(message: 'Retrying...', level: :info, ttl: 2)
|
|
132
|
+
@message_stream.add_message(role: :assistant, content: '')
|
|
133
|
+
send_to_llm(@last_user_input)
|
|
134
|
+
:handled
|
|
135
|
+
end
|
|
119
136
|
end
|
|
137
|
+
# rubocop:enable Metrics/ModuleLength
|
|
120
138
|
end
|
|
121
139
|
end
|
|
122
140
|
end
|
|
@@ -156,6 +156,29 @@ module Legion
|
|
|
156
156
|
rescue StandardError
|
|
157
157
|
nil
|
|
158
158
|
end
|
|
159
|
+
|
|
160
|
+
def handle_merge(input)
|
|
161
|
+
name = input.split(nil, 2)[1]
|
|
162
|
+
unless name
|
|
163
|
+
@message_stream.add_message(role: :system, content: 'Usage: /merge <session-name>')
|
|
164
|
+
return :handled
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
data = @session_store.load(name)
|
|
168
|
+
unless data
|
|
169
|
+
@message_stream.add_message(role: :system, content: 'Session not found.')
|
|
170
|
+
return :handled
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
imported = data[:messages]
|
|
174
|
+
@message_stream.messages.concat(imported)
|
|
175
|
+
@status_bar.update(message_count: @message_stream.messages.size)
|
|
176
|
+
@message_stream.add_message(
|
|
177
|
+
role: :system,
|
|
178
|
+
content: "Merged #{imported.size} messages from '#{name}'."
|
|
179
|
+
)
|
|
180
|
+
:handled
|
|
181
|
+
end
|
|
159
182
|
end
|
|
160
183
|
# rubocop:enable Metrics/ModuleLength
|
|
161
184
|
end
|
|
@@ -277,6 +277,42 @@ 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
|
|
306
|
+
|
|
307
|
+
def handle_focus
|
|
308
|
+
@focus_mode = !@focus_mode
|
|
309
|
+
if @focus_mode
|
|
310
|
+
@status_bar.notify(message: 'Focus mode ON', level: :info, ttl: 2)
|
|
311
|
+
else
|
|
312
|
+
@status_bar.notify(message: 'Focus mode OFF', level: :info, ttl: 2)
|
|
313
|
+
end
|
|
314
|
+
:handled
|
|
315
|
+
end
|
|
280
316
|
end
|
|
281
317
|
# rubocop:enable Metrics/ModuleLength
|
|
282
318
|
end
|
|
@@ -29,7 +29,9 @@ 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 /repeat /count
|
|
32
|
+
/wc /import /mute /autosave /react /macro /tag /tags /repeat /count
|
|
33
|
+
/template /fav /favs /log /version
|
|
34
|
+
/focus /retry /merge /sort].freeze
|
|
33
35
|
|
|
34
36
|
PERSONALITIES = {
|
|
35
37
|
'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
|
|
@@ -67,6 +69,8 @@ module Legion
|
|
|
67
69
|
@recording_macro = nil
|
|
68
70
|
@macro_buffer = []
|
|
69
71
|
@last_command = nil
|
|
72
|
+
@focus_mode = false
|
|
73
|
+
@last_user_input = nil
|
|
70
74
|
end
|
|
71
75
|
|
|
72
76
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -130,6 +134,7 @@ module Legion
|
|
|
130
134
|
end
|
|
131
135
|
|
|
132
136
|
def handle_user_message(input)
|
|
137
|
+
@last_user_input = input
|
|
133
138
|
@message_stream.add_message(role: :user, content: input)
|
|
134
139
|
if @plan_mode
|
|
135
140
|
@message_stream.add_message(role: :system, content: '(bookmarked)')
|
|
@@ -159,16 +164,9 @@ module Legion
|
|
|
159
164
|
end
|
|
160
165
|
|
|
161
166
|
def render(width, height)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
extra_rows = dbg ? 1 : 0
|
|
166
|
-
stream_height = [height - 2 - extra_rows, 1].max
|
|
167
|
-
stream_lines = @message_stream.render(width: width, height: stream_height)
|
|
168
|
-
@status_bar.update(scroll: @message_stream.scroll_position)
|
|
169
|
-
lines = stream_lines + [divider, bar_line]
|
|
170
|
-
lines << dbg if dbg
|
|
171
|
-
lines
|
|
167
|
+
return render_focus(width, height) if @focus_mode
|
|
168
|
+
|
|
169
|
+
render_normal(width, height)
|
|
172
170
|
end
|
|
173
171
|
|
|
174
172
|
def handle_input(key)
|
|
@@ -186,6 +184,25 @@ module Legion
|
|
|
186
184
|
|
|
187
185
|
private
|
|
188
186
|
|
|
187
|
+
def render_focus(width, height)
|
|
188
|
+
stream_lines = @message_stream.render(width: width, height: [height, 1].max)
|
|
189
|
+
@status_bar.update(scroll: @message_stream.scroll_position)
|
|
190
|
+
stream_lines
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def render_normal(width, height)
|
|
194
|
+
bar_line = @status_bar.render(width: width)
|
|
195
|
+
divider = Theme.c(:muted, '-' * width)
|
|
196
|
+
dbg = debug_segment
|
|
197
|
+
extra_rows = dbg ? 1 : 0
|
|
198
|
+
stream_height = [height - 2 - extra_rows, 1].max
|
|
199
|
+
stream_lines = @message_stream.render(width: width, height: stream_height)
|
|
200
|
+
@status_bar.update(scroll: @message_stream.scroll_position)
|
|
201
|
+
lines = stream_lines + [divider, bar_line]
|
|
202
|
+
lines << dbg if dbg
|
|
203
|
+
lines
|
|
204
|
+
end
|
|
205
|
+
|
|
189
206
|
def record_macro_step(input, cmd, result)
|
|
190
207
|
return unless @recording_macro
|
|
191
208
|
return if cmd == '/macro'
|
|
@@ -379,6 +396,15 @@ module Legion
|
|
|
379
396
|
when '/tags' then handle_tags(input)
|
|
380
397
|
when '/repeat' then handle_repeat
|
|
381
398
|
when '/count' then handle_count(input)
|
|
399
|
+
when '/template' then handle_template(input)
|
|
400
|
+
when '/fav' then handle_fav(input)
|
|
401
|
+
when '/favs' then handle_favs
|
|
402
|
+
when '/log' then handle_log(input)
|
|
403
|
+
when '/version' then handle_version
|
|
404
|
+
when '/focus' then handle_focus
|
|
405
|
+
when '/retry' then handle_retry
|
|
406
|
+
when '/merge' then handle_merge(input)
|
|
407
|
+
when '/sort' then handle_sort(input)
|
|
382
408
|
else :handled
|
|
383
409
|
end
|
|
384
410
|
end
|
data/lib/legion/tty/version.rb
CHANGED