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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b54f067b654097c2ea13e9a36aed12ac6e79f01a7e737494fa57da489d9ada9
4
- data.tar.gz: 767c21b9ed0f1ac37f92db12bbef79432d42de65695d07736c9e1478b48ef96d
3
+ metadata.gz: 6a862cd998ed05b3eb9a633c41602683425e79ea59f9634f0c2826e24e981291
4
+ data.tar.gz: 7cda3afc03dfa47d640d7fd4eca2dce80c8701fea820e3077e7bf3f4a37fc30d
5
5
  SHA512:
6
- metadata.gz: 6cec6ebd2ee78648a6f8d00e5db9c3004d1b9e1787b914a31135b1ed75137d467c9982363c83dc40f8697b9ef930869e9ab719b066297e5dc61011361a8f5f22
7
- data.tar.gz: 3b1f39208ef1198f3db8620aee08f56d65146f3d761d5356b04992aadd79514aae957e044384878181b390cf738e2258ea08fadf6c4299c3c1aaf1bba0e4b7f4
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.6
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\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
- path = File.join(exports_dir, "chat-#{timestamp}.#{format == 'json' ? 'json' : 'md'}")
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('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.7'
5
+ VERSION = '0.4.9'
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.7
4
+ version: 0.4.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity