legion-tty 0.4.7 → 0.4.9
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/README.md +1 -1
- data/lib/legion/tty/components/status_bar.rb +14 -0
- data/lib/legion/tty/screens/chat.rb +142 -12
- data/lib/legion/tty/screens/config.rb +11 -1
- data/lib/legion/tty/screens/extensions.rb +29 -1
- 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: 6a862cd998ed05b3eb9a633c41602683425e79ea59f9634f0c2826e24e981291
|
|
4
|
+
data.tar.gz: 7cda3afc03dfa47d640d7fd4eca2dce80c8701fea820e3077e7bf3f4a37fc30d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f65b4afeeed674a49e59a43e4116eee383db5bdc2083cd648342c9134ba2e971d8403d8d3fc57ebbcacacbf9dcec293d482cce818a97c84ce4485d843ad1490
|
|
7
|
+
data.tar.gz: cd51d5ede4592418fe9a25226c054a2cf26adec493f7efc94763c6165c4bef50b1fdfe74f039759fb4cabdf0fec36ab679b643f28273f8a8573011e8a063c3d8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.9] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- StatusBar notifications: transient toast-style messages with TTL expiry (wired to save/load/export/theme)
|
|
7
|
+
- `/undo` command: remove last user+assistant message pair
|
|
8
|
+
- `/history` command: show last 20 input entries
|
|
9
|
+
- `/pin` and `/pins` commands: pin important messages, view pinned list
|
|
10
|
+
- `/rename <name>` command: rename current session (deletes old, saves new)
|
|
11
|
+
|
|
12
|
+
## [0.4.8] - 2026-03-19
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `/export html` format: dark-theme HTML export with XSS-safe content escaping
|
|
16
|
+
- Extension homepage opener: press 'o' in extensions browser to open gem homepage in browser
|
|
17
|
+
- Config JSON validation: validates data before saving to prevent corrupt config files
|
|
18
|
+
|
|
3
19
|
## [0.4.7] - 2026-03-19
|
|
4
20
|
|
|
5
21
|
### Added
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Rich terminal UI for the LegionIO async cognition engine.
|
|
4
4
|
|
|
5
|
-
**Version**: 0.4.
|
|
5
|
+
**Version**: 0.4.8
|
|
6
6
|
|
|
7
7
|
Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell, operational dashboard, extensions browser, config editor, and session persistence - all rendered with the [tty-ruby](https://ttytoolkit.org/) gem ecosystem.
|
|
8
8
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../theme'
|
|
4
|
+
require_relative 'notification'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
6
7
|
module TTY
|
|
@@ -8,6 +9,11 @@ module Legion
|
|
|
8
9
|
class StatusBar
|
|
9
10
|
def initialize
|
|
10
11
|
@state = { model: nil, tokens: 0, cost: 0.0, session: 'default', thinking: false, plan_mode: false }
|
|
12
|
+
@notifications = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def notify(message:, level: :info, ttl: 5)
|
|
16
|
+
@notifications << Notification.new(message: message, level: level, ttl: ttl)
|
|
11
17
|
end
|
|
12
18
|
|
|
13
19
|
def update(**fields)
|
|
@@ -35,6 +41,7 @@ module Legion
|
|
|
35
41
|
model_segment,
|
|
36
42
|
plan_segment,
|
|
37
43
|
thinking_segment,
|
|
44
|
+
notification_segment,
|
|
38
45
|
tokens_segment,
|
|
39
46
|
cost_segment,
|
|
40
47
|
session_segment,
|
|
@@ -60,6 +67,13 @@ module Legion
|
|
|
60
67
|
Theme.c(:warning, "#{frame} thinking...")
|
|
61
68
|
end
|
|
62
69
|
|
|
70
|
+
def notification_segment
|
|
71
|
+
@notifications.reject!(&:expired?)
|
|
72
|
+
return nil if @notifications.empty?
|
|
73
|
+
|
|
74
|
+
@notifications.first.render
|
|
75
|
+
end
|
|
76
|
+
|
|
63
77
|
def tokens_segment
|
|
64
78
|
Theme.c(:secondary, "#{format_number(@state[:tokens])} tokens") if @state[:tokens].to_i.positive?
|
|
65
79
|
end
|
|
@@ -14,7 +14,7 @@ module Legion
|
|
|
14
14
|
class Chat < Base
|
|
15
15
|
SLASH_COMMANDS = %w[/help /quit /clear /compact /copy /diff /model /session /cost /export /tools /dashboard
|
|
16
16
|
/hotkeys /save /load /sessions /system /delete /plan /palette /extensions /config
|
|
17
|
-
/theme /search /stats /personality].freeze
|
|
17
|
+
/theme /search /stats /personality /undo /history /pin /pins /rename].freeze
|
|
18
18
|
|
|
19
19
|
PERSONALITIES = {
|
|
20
20
|
'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
|
|
@@ -38,6 +38,7 @@ module Legion
|
|
|
38
38
|
@session_store = SessionStore.new
|
|
39
39
|
@session_name = 'default'
|
|
40
40
|
@plan_mode = false
|
|
41
|
+
@pinned_messages = []
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
def activate
|
|
@@ -292,6 +293,11 @@ module Legion
|
|
|
292
293
|
when '/search' then handle_search(input)
|
|
293
294
|
when '/stats' then handle_stats
|
|
294
295
|
when '/personality' then handle_personality(input)
|
|
296
|
+
when '/undo' then handle_undo
|
|
297
|
+
when '/history' then handle_history
|
|
298
|
+
when '/pin' then handle_pin(input)
|
|
299
|
+
when '/pins' then handle_pins
|
|
300
|
+
when '/rename' then handle_rename(input)
|
|
295
301
|
else :handled
|
|
296
302
|
end
|
|
297
303
|
end
|
|
@@ -309,7 +315,12 @@ module Legion
|
|
|
309
315
|
"/copy -- copy last assistant message to clipboard\n " \
|
|
310
316
|
"/diff -- show new messages since session was loaded\n " \
|
|
311
317
|
"/stats -- show conversation statistics\n " \
|
|
312
|
-
"/personality [name] -- switch assistant personality\n
|
|
318
|
+
"/personality [name] -- switch assistant personality\n " \
|
|
319
|
+
"/undo -- remove last user+assistant message pair\n " \
|
|
320
|
+
"/history -- show recent input history\n " \
|
|
321
|
+
"/pin [N] -- pin last assistant message (or message at index N)\n " \
|
|
322
|
+
"/pins -- show all pinned messages\n " \
|
|
323
|
+
"/rename <name> -- rename current session\n\n" \
|
|
313
324
|
'Hotkeys: Ctrl+D=dashboard Ctrl+K=palette Ctrl+S=sessions Esc=back'
|
|
314
325
|
)
|
|
315
326
|
:handled
|
|
@@ -403,6 +414,7 @@ module Legion
|
|
|
403
414
|
@session_name = name
|
|
404
415
|
@session_store.save(name, messages: @message_stream.messages)
|
|
405
416
|
@status_bar.update(session: name)
|
|
417
|
+
@status_bar.notify(message: "Saved '#{name}'", level: :success, ttl: 3)
|
|
406
418
|
@message_stream.add_message(role: :system, content: "Session saved as '#{name}'.")
|
|
407
419
|
:handled
|
|
408
420
|
end
|
|
@@ -422,6 +434,7 @@ module Legion
|
|
|
422
434
|
@loaded_message_count = @message_stream.messages.size
|
|
423
435
|
@session_name = name
|
|
424
436
|
@status_bar.update(session: name)
|
|
437
|
+
@status_bar.notify(message: "Loaded '#{name}'", level: :info, ttl: 3)
|
|
425
438
|
@message_stream.add_message(role: :system,
|
|
426
439
|
content: "Session '#{name}' loaded (#{data[:messages].size} messages).")
|
|
427
440
|
:handled
|
|
@@ -454,29 +467,38 @@ module Legion
|
|
|
454
467
|
:handled
|
|
455
468
|
end
|
|
456
469
|
|
|
457
|
-
# rubocop:disable Metrics/AbcSize
|
|
458
470
|
def handle_export(input)
|
|
459
471
|
require 'fileutils'
|
|
472
|
+
path = build_export_path(input)
|
|
473
|
+
dispatch_export(path, input.split[1]&.downcase)
|
|
474
|
+
@status_bar.notify(message: 'Exported', level: :success, ttl: 3)
|
|
475
|
+
@message_stream.add_message(role: :system, content: "Exported to: #{path}")
|
|
476
|
+
:handled
|
|
477
|
+
rescue StandardError => e
|
|
478
|
+
@message_stream.add_message(role: :system, content: "Export failed: #{e.message}")
|
|
479
|
+
:handled
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def build_export_path(input)
|
|
460
483
|
format = input.split[1]&.downcase
|
|
461
|
-
format = 'md' unless %w[json md].include?(format)
|
|
484
|
+
format = 'md' unless %w[json md html].include?(format)
|
|
462
485
|
exports_dir = File.expand_path('~/.legionio/exports')
|
|
463
486
|
FileUtils.mkdir_p(exports_dir)
|
|
464
487
|
timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
|
|
465
|
-
|
|
488
|
+
ext = { 'json' => 'json', 'md' => 'md', 'html' => 'html' }[format]
|
|
489
|
+
File.join(exports_dir, "chat-#{timestamp}.#{ext}")
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def dispatch_export(path, format)
|
|
466
493
|
if format == 'json'
|
|
467
494
|
export_json(path)
|
|
495
|
+
elsif format == 'html'
|
|
496
|
+
export_html(path)
|
|
468
497
|
else
|
|
469
498
|
export_markdown(path)
|
|
470
499
|
end
|
|
471
|
-
@message_stream.add_message(role: :system, content: "Exported to: #{path}")
|
|
472
|
-
:handled
|
|
473
|
-
rescue StandardError => e
|
|
474
|
-
@message_stream.add_message(role: :system, content: "Export failed: #{e.message}")
|
|
475
|
-
:handled
|
|
476
500
|
end
|
|
477
501
|
|
|
478
|
-
# rubocop:enable Metrics/AbcSize
|
|
479
|
-
|
|
480
502
|
# rubocop:disable Metrics/AbcSize
|
|
481
503
|
def handle_tools
|
|
482
504
|
lex_gems = Gem::Specification.select { |s| s.name.start_with?('lex-') }
|
|
@@ -602,6 +624,7 @@ module Legion
|
|
|
602
624
|
name = input.split(nil, 2)[1]
|
|
603
625
|
if name
|
|
604
626
|
if Theme.switch(name)
|
|
627
|
+
@status_bar.notify(message: "Theme: #{name}", level: :info, ttl: 2)
|
|
605
628
|
@message_stream.add_message(role: :system, content: "Theme switched to: #{name}")
|
|
606
629
|
else
|
|
607
630
|
available = Theme.available_themes.join(', ')
|
|
@@ -815,6 +838,113 @@ module Legion
|
|
|
815
838
|
File.write(path, ::JSON.pretty_generate(data))
|
|
816
839
|
end
|
|
817
840
|
|
|
841
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
842
|
+
def export_html(path)
|
|
843
|
+
lines = [
|
|
844
|
+
'<!DOCTYPE html><html><head>',
|
|
845
|
+
'<meta charset="utf-8">',
|
|
846
|
+
'<title>Chat Export</title>',
|
|
847
|
+
'<style>',
|
|
848
|
+
'body { font-family: system-ui; max-width: 800px; margin: 0 auto; ' \
|
|
849
|
+
'padding: 20px; background: #1e1b2e; color: #d0cce6; }',
|
|
850
|
+
'.msg { margin: 12px 0; padding: 8px 12px; border-radius: 8px; }',
|
|
851
|
+
'.user { background: #2a2640; }',
|
|
852
|
+
'.assistant { background: #1a1730; }',
|
|
853
|
+
'.system { background: #25223a; color: #8b85a8; font-style: italic; }',
|
|
854
|
+
'.role { font-weight: bold; color: #9d91e6; font-size: 0.85em; }',
|
|
855
|
+
'</style></head><body>',
|
|
856
|
+
'<h1>Chat Export</h1>',
|
|
857
|
+
"<p>Exported: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}</p>"
|
|
858
|
+
]
|
|
859
|
+
@message_stream.messages.each do |msg|
|
|
860
|
+
role = msg[:role].to_s
|
|
861
|
+
content = escape_html(msg[:content].to_s).gsub("\n", '<br>')
|
|
862
|
+
lines << "<div class='msg #{role}'>"
|
|
863
|
+
lines << "<span class='role'>#{role.capitalize}</span>"
|
|
864
|
+
lines << "<p>#{content}</p>"
|
|
865
|
+
lines << '</div>'
|
|
866
|
+
end
|
|
867
|
+
lines << '</body></html>'
|
|
868
|
+
File.write(path, lines.join("\n"))
|
|
869
|
+
end
|
|
870
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
871
|
+
|
|
872
|
+
def escape_html(text)
|
|
873
|
+
text.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
def handle_undo
|
|
877
|
+
msgs = @message_stream.messages
|
|
878
|
+
last_user_idx = msgs.rindex { |m| m[:role] == :user }
|
|
879
|
+
unless last_user_idx
|
|
880
|
+
@message_stream.add_message(role: :system, content: 'Nothing to undo.')
|
|
881
|
+
return :handled
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
msgs.slice!(last_user_idx..)
|
|
885
|
+
:handled
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def handle_history
|
|
889
|
+
entries = @input_bar.history
|
|
890
|
+
if entries.empty?
|
|
891
|
+
@message_stream.add_message(role: :system, content: 'No input history.')
|
|
892
|
+
else
|
|
893
|
+
recent = entries.last(20)
|
|
894
|
+
lines = recent.each_with_index.map { |entry, i| " #{i + 1}. #{entry}" }
|
|
895
|
+
@message_stream.add_message(role: :system,
|
|
896
|
+
content: "Input history (last #{recent.size}):\n#{lines.join("\n")}")
|
|
897
|
+
end
|
|
898
|
+
:handled
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def handle_pin(input)
|
|
902
|
+
idx_str = input.split(nil, 2)[1]
|
|
903
|
+
msg = if idx_str
|
|
904
|
+
@message_stream.messages[idx_str.to_i]
|
|
905
|
+
else
|
|
906
|
+
@message_stream.messages.reverse.find { |m| m[:role] == :assistant }
|
|
907
|
+
end
|
|
908
|
+
unless msg
|
|
909
|
+
@message_stream.add_message(role: :system, content: 'No message to pin.')
|
|
910
|
+
return :handled
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
@pinned_messages << msg
|
|
914
|
+
preview = truncate_text(msg[:content].to_s, 60)
|
|
915
|
+
@message_stream.add_message(role: :system, content: "Pinned: #{preview}")
|
|
916
|
+
:handled
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
def handle_pins
|
|
920
|
+
if @pinned_messages.empty?
|
|
921
|
+
@message_stream.add_message(role: :system, content: 'No pinned messages.')
|
|
922
|
+
else
|
|
923
|
+
lines = @pinned_messages.each_with_index.map do |msg, i|
|
|
924
|
+
" #{i + 1}. [#{msg[:role]}] #{truncate_text(msg[:content].to_s, 70)}"
|
|
925
|
+
end
|
|
926
|
+
@message_stream.add_message(role: :system,
|
|
927
|
+
content: "Pinned messages (#{@pinned_messages.size}):\n#{lines.join("\n")}")
|
|
928
|
+
end
|
|
929
|
+
:handled
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def handle_rename(input)
|
|
933
|
+
name = input.split(nil, 2)[1]
|
|
934
|
+
unless name
|
|
935
|
+
@message_stream.add_message(role: :system, content: 'Usage: /rename <new-name>')
|
|
936
|
+
return :handled
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
old_name = @session_name
|
|
940
|
+
@session_store.delete(old_name) if old_name != 'default'
|
|
941
|
+
@session_name = name
|
|
942
|
+
@status_bar.update(session: name)
|
|
943
|
+
@session_store.save(name, messages: @message_stream.messages)
|
|
944
|
+
@message_stream.add_message(role: :system, content: "Session renamed to '#{name}'.")
|
|
945
|
+
:handled
|
|
946
|
+
end
|
|
947
|
+
|
|
818
948
|
def build_default_input_bar
|
|
819
949
|
cfg = safe_config
|
|
820
950
|
name = cfg[:name] || 'User'
|
|
@@ -100,7 +100,7 @@ module Legion
|
|
|
100
100
|
@viewing_file = true
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
def edit_selected_key # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
103
|
+
def edit_selected_key # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
104
104
|
keys = @file_data.keys
|
|
105
105
|
return unless keys[@selected_key]
|
|
106
106
|
|
|
@@ -115,11 +115,21 @@ module Legion
|
|
|
115
115
|
return if new_val.nil? || new_val == '********'
|
|
116
116
|
|
|
117
117
|
@file_data[key] = new_val
|
|
118
|
+
return unless validate_config(@file_data)
|
|
119
|
+
|
|
118
120
|
save_current_file
|
|
119
121
|
rescue ::TTY::Reader::InputInterrupt, Interrupt
|
|
120
122
|
nil
|
|
121
123
|
end
|
|
122
124
|
|
|
125
|
+
def validate_config(data)
|
|
126
|
+
::JSON.generate(data)
|
|
127
|
+
true
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
@messages = ["Invalid JSON: #{e.message}"]
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
|
|
123
133
|
def save_current_file
|
|
124
134
|
return unless @files[@selected_file]
|
|
125
135
|
|
|
@@ -42,10 +42,11 @@ module Legion
|
|
|
42
42
|
else
|
|
43
43
|
list_lines(height - 4)
|
|
44
44
|
end
|
|
45
|
-
lines += ['', Theme.c(:muted, ' Enter=detail q=back')]
|
|
45
|
+
lines += ['', Theme.c(:muted, ' Enter=detail o=open q=back')]
|
|
46
46
|
pad_lines(lines, height)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# rubocop:disable Metrics/MethodLength
|
|
49
50
|
def handle_input(key)
|
|
50
51
|
case key
|
|
51
52
|
when :up
|
|
@@ -57,6 +58,9 @@ module Legion
|
|
|
57
58
|
when :enter
|
|
58
59
|
@detail = !@detail
|
|
59
60
|
:handled
|
|
61
|
+
when 'o'
|
|
62
|
+
open_homepage
|
|
63
|
+
:handled
|
|
60
64
|
when 'q', :escape
|
|
61
65
|
if @detail
|
|
62
66
|
@detail = false
|
|
@@ -68,6 +72,7 @@ module Legion
|
|
|
68
72
|
:pass
|
|
69
73
|
end
|
|
70
74
|
end
|
|
75
|
+
# rubocop:enable Metrics/MethodLength
|
|
71
76
|
|
|
72
77
|
private
|
|
73
78
|
|
|
@@ -127,6 +132,29 @@ module Legion
|
|
|
127
132
|
]
|
|
128
133
|
end
|
|
129
134
|
|
|
135
|
+
def open_homepage
|
|
136
|
+
entry = current_gem
|
|
137
|
+
return unless entry && entry[:homepage]
|
|
138
|
+
|
|
139
|
+
system_open(entry[:homepage])
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def system_open(url)
|
|
145
|
+
case RUBY_PLATFORM
|
|
146
|
+
when /darwin/ then system('open', url)
|
|
147
|
+
when /linux/ then system('xdg-open', url)
|
|
148
|
+
when /mingw|mswin/ then system('start', url)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def current_gem
|
|
153
|
+
return nil if @gems.empty?
|
|
154
|
+
|
|
155
|
+
@gems[@selected]
|
|
156
|
+
end
|
|
157
|
+
|
|
130
158
|
def pad_lines(lines, height)
|
|
131
159
|
lines + Array.new([height - lines.size, 0].max, '')
|
|
132
160
|
end
|
data/lib/legion/tty/version.rb
CHANGED