legion-tty 0.4.6 → 0.4.8

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: 750c41521ce23860a6e8613b4e84471e4c2813657ec68f23b24fcf0aa7015ecd
4
- data.tar.gz: b203df2393abcc0afa085b7ec30c73fbbe3815d2f9886d1b3744fdbbf1fddc62
3
+ metadata.gz: 49d5dd5b69b7fa3412226bc38e93b0adb0df6be7c3b0317955bf7f9698a0759d
4
+ data.tar.gz: ed73f018bbb85ee31fdf11aba24f5b6f332f28ef85970f9189ba5164402d8690
5
5
  SHA512:
6
- metadata.gz: 06d4eab7afdfbdb8933c3166c3b5dc3cd028c7462f29d4601364ba759ae9b648bbdd1a7e4f304587dcc1148af166840815bba87d5c81044cd622355964cb9cc1
7
- data.tar.gz: e331268056bf882144790fe0c88f95454050a6b16a017b033f00e29785f71a027107784fc8a4e25906a3d3cd733c0d20454577423d2e5a038b21b6d9fb490c29
6
+ metadata.gz: 67a87dfd8538f4c42e5c9897d4460d8457071660e95df3fe019757d3e9e56ef95627d4a5a3576914611257f78fd766190909e4e95f8f4bee26b1c0328745c35f
7
+ data.tar.gz: a6f9bd2210d04ad3d2200a4029e32141eb82b29043dee974cb5b37d5d6b927b1f2b284cac1d3e39c77c6105fa7c08f61a3da8a678e4d77e88de675cfe9cac2ea
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.8] - 2026-03-19
4
+
5
+ ### Added
6
+ - `/export html` format: dark-theme HTML export with XSS-safe content escaping
7
+ - Extension homepage opener: press 'o' in extensions browser to open gem homepage in browser
8
+ - Config JSON validation: validates data before saving to prevent corrupt config files
9
+
10
+ ## [0.4.7] - 2026-03-19
11
+
12
+ ### Added
13
+ - Smart session auto-naming: generates slug from first user message instead of "default"
14
+ - Message timestamps: each message records creation time, displayed in user message headers
15
+ - Scroll position indicator in status bar (shows current/total when content is scrollable)
16
+
3
17
  ## [0.4.6] - 2026-03-19
4
18
 
5
19
  ### 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.1
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
 
@@ -10,7 +10,7 @@ Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with iden
10
10
 
11
11
  - **Onboarding wizard** - First-run setup with Kerberos identity detection, GitHub profile probing, environment scanning, and LLM provider selection
12
12
  - **Digital rain intro** - Matrix-style rain using discovered LEX extension names
13
- - **AI chat shell** - Streaming LLM chat with 19 slash commands, tab completion, markdown rendering, and tool panels
13
+ - **AI chat shell** - Streaming LLM chat with 27 slash commands, tab completion, markdown rendering, and tool panels
14
14
  - **Operational dashboard** - Service status, extension inventory, system info, recent activity (Ctrl+D or `/dashboard`)
15
15
  - **Extensions browser** - Browse installed LEX gems by category (core, agentic, service, AI, other) with detail view
16
16
  - **Config viewer/editor** - View and edit `~/.legionio/settings/*.json` with vault:// masking
@@ -19,9 +19,15 @@ Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with iden
19
19
  - **Session management** - Auto-save on quit, `/save`, `/load`, `/sessions`, session picker (Ctrl+S)
20
20
  - **Token tracking** - Per-model pricing for 9 models across 8 providers via `/cost`
21
21
  - **Plan mode** - Bookmark messages without sending to LLM (`/plan`)
22
+ - **Personality styles** - Switch between default, concise, detailed, friendly, technical (`/personality`)
23
+ - **Theme selection** - Four built-in themes: purple (default), green, blue, amber (`/theme`)
24
+ - **Conversation tools** - `/compact`, `/copy`, `/diff`, `/search`, `/stats`
22
25
  - **Hotkey navigation** - Ctrl+D (dashboard), Ctrl+K (palette), Ctrl+S (sessions), Escape (back)
23
26
  - **Tab completion** - Type `/` and Tab to auto-complete slash commands
27
+ - **Input history** - Up/down arrow to navigate previous inputs
24
28
  - **Progress panel** - Visual progress bars for long operations (extension scanning, gem ops)
29
+ - **Animated spinner** - Status bar spinner during LLM thinking
30
+ - **Daemon routing** - Routes through LegionIO daemon when available, falls back to direct
25
31
  - **Second-run flow** - Skips onboarding, re-scans environment, drops into chat
26
32
 
27
33
  ## Installation
@@ -83,6 +89,13 @@ legion chat prompt "explain async cognition"
83
89
  | `/palette` | Open command palette (fuzzy search) |
84
90
  | `/extensions` | Browse installed LEX extensions |
85
91
  | `/config` | View and edit settings files |
92
+ | `/theme [name]` | Switch color theme (purple/green/blue/amber) |
93
+ | `/search <text>` | Search message history |
94
+ | `/compact [N]` | Keep last N message pairs, remove older |
95
+ | `/copy` | Copy last assistant response to clipboard |
96
+ | `/diff` | Show new messages since last session load |
97
+ | `/stats` | Show conversation statistics |
98
+ | `/personality [style]` | Switch personality (default/concise/detailed/friendly/technical) |
86
99
 
87
100
  ## Hotkeys
88
101
 
@@ -126,6 +139,7 @@ legion-tty
126
139
  SessionPicker # Session list and selection
127
140
  TableView # TTY::Table wrapper
128
141
  ProgressPanel # TTY::ProgressBar wrapper
142
+ Notification # Transient notifications with TTL and levels
129
143
 
130
144
  Background/
131
145
  Scanner # Service port probing, git repo discovery, shell history
@@ -154,8 +168,8 @@ Boot logs go to `~/.legionio/logs/tty-boot.log`.
154
168
 
155
169
  ```bash
156
170
  bundle install
157
- bundle exec rspec # 598 examples, 0 failures
158
- bundle exec rubocop # 68 files, 0 offenses
171
+ bundle exec rspec # 653 examples, 0 failures
172
+ bundle exec rubocop # 77 files, 0 offenses
159
173
  ```
160
174
 
161
175
  ## License
@@ -14,7 +14,7 @@ module Legion
14
14
  end
15
15
 
16
16
  def add_message(role:, content:)
17
- @messages << { role: role, content: content, tool_panels: [] }
17
+ @messages << { role: role, content: content, tool_panels: [], timestamp: Time.now }
18
18
  end
19
19
 
20
20
  def append_streaming(text)
@@ -57,7 +57,13 @@ module Legion
57
57
  total = all_lines.size
58
58
  start_idx = [total - height - @scroll_offset, 0].max
59
59
  start_idx = [start_idx, total].min
60
- all_lines[start_idx, height] || []
60
+ result = all_lines[start_idx, height] || []
61
+ @last_visible_count = result.size
62
+ result
63
+ end
64
+
65
+ def scroll_position
66
+ { current: @scroll_offset, total: @messages.size, visible: @last_visible_count || 0 }
61
67
  end
62
68
 
63
69
  private
@@ -72,7 +78,7 @@ module Legion
72
78
 
73
79
  def role_lines(msg, width)
74
80
  case msg[:role]
75
- when :user then user_lines(msg)
81
+ when :user then user_lines(msg, width)
76
82
  when :assistant then assistant_lines(msg, width)
77
83
  when :system then system_lines(msg)
78
84
  when :tool then tool_call_lines(msg, width)
@@ -80,9 +86,16 @@ module Legion
80
86
  end
81
87
  end
82
88
 
83
- def user_lines(msg)
84
- prefix = Theme.c(:accent, 'You')
85
- ['', "#{prefix}: #{msg[:content]}"]
89
+ def user_lines(msg, _width)
90
+ ts = format_timestamp(msg[:timestamp])
91
+ header = "#{Theme.c(:accent, 'You')} #{Theme.c(:muted, ts)}"
92
+ ['', "#{header}: #{msg[:content]}"]
93
+ end
94
+
95
+ def format_timestamp(time)
96
+ return '' unless time
97
+
98
+ time.strftime('%H:%M')
86
99
  end
87
100
 
88
101
  def assistant_lines(msg, width)
@@ -37,7 +37,8 @@ module Legion
37
37
  thinking_segment,
38
38
  tokens_segment,
39
39
  cost_segment,
40
- session_segment
40
+ session_segment,
41
+ scroll_segment
41
42
  ].compact
42
43
  end
43
44
 
@@ -71,6 +72,13 @@ module Legion
71
72
  Theme.c(:muted, @state[:session]) if @state[:session]
72
73
  end
73
74
 
75
+ def scroll_segment
76
+ scroll = @state[:scroll]
77
+ return nil unless scroll.is_a?(Hash) && scroll[:total].to_i > scroll[:visible].to_i
78
+
79
+ Theme.c(:muted, "#{scroll[:current]}/#{scroll[:total]}")
80
+ end
81
+
74
82
  def format_number(num)
75
83
  num.to_s.chars.reverse.each_slice(3).map(&:join).join(',').reverse
76
84
  end
@@ -121,6 +121,7 @@ module Legion
121
121
  divider = Theme.c(:muted, '-' * width)
122
122
  stream_height = [height - 2, 1].max
123
123
  stream_lines = @message_stream.render(width: width, height: stream_height)
124
+ @status_bar.update(scroll: @message_stream.scroll_position)
124
125
  stream_lines + [divider, bar_line]
125
126
  end
126
127
 
@@ -440,6 +441,9 @@ module Legion
440
441
  def auto_save_session
441
442
  return if @message_stream.messages.empty?
442
443
 
444
+ if @session_name == 'default'
445
+ @session_name = @session_store.auto_session_name(messages: @message_stream.messages)
446
+ end
443
447
  @session_store.save(@session_name, messages: @message_stream.messages)
444
448
  rescue StandardError
445
449
  nil
@@ -454,13 +458,16 @@ module Legion
454
458
  def handle_export(input)
455
459
  require 'fileutils'
456
460
  format = input.split[1]&.downcase
457
- format = 'md' unless %w[json md].include?(format)
461
+ format = 'md' unless %w[json md html].include?(format)
458
462
  exports_dir = File.expand_path('~/.legionio/exports')
459
463
  FileUtils.mkdir_p(exports_dir)
460
464
  timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
461
- path = File.join(exports_dir, "chat-#{timestamp}.#{format == 'json' ? 'json' : 'md'}")
465
+ ext = { 'json' => 'json', 'md' => 'md', 'html' => 'html' }[format]
466
+ path = File.join(exports_dir, "chat-#{timestamp}.#{ext}")
462
467
  if format == 'json'
463
468
  export_json(path)
469
+ elsif format == 'html'
470
+ export_html(path)
464
471
  else
465
472
  export_markdown(path)
466
473
  end
@@ -811,6 +818,41 @@ module Legion
811
818
  File.write(path, ::JSON.pretty_generate(data))
812
819
  end
813
820
 
821
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
822
+ def export_html(path)
823
+ lines = [
824
+ '<!DOCTYPE html><html><head>',
825
+ '<meta charset="utf-8">',
826
+ '<title>Chat Export</title>',
827
+ '<style>',
828
+ 'body { font-family: system-ui; max-width: 800px; margin: 0 auto; ' \
829
+ 'padding: 20px; background: #1e1b2e; color: #d0cce6; }',
830
+ '.msg { margin: 12px 0; padding: 8px 12px; border-radius: 8px; }',
831
+ '.user { background: #2a2640; }',
832
+ '.assistant { background: #1a1730; }',
833
+ '.system { background: #25223a; color: #8b85a8; font-style: italic; }',
834
+ '.role { font-weight: bold; color: #9d91e6; font-size: 0.85em; }',
835
+ '</style></head><body>',
836
+ '<h1>Chat Export</h1>',
837
+ "<p>Exported: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}</p>"
838
+ ]
839
+ @message_stream.messages.each do |msg|
840
+ role = msg[:role].to_s
841
+ content = escape_html(msg[:content].to_s).gsub("\n", '<br>')
842
+ lines << "<div class='msg #{role}'>"
843
+ lines << "<span class='role'>#{role.capitalize}</span>"
844
+ lines << "<p>#{content}</p>"
845
+ lines << '</div>'
846
+ end
847
+ lines << '</body></html>'
848
+ File.write(path, lines.join("\n"))
849
+ end
850
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
851
+
852
+ def escape_html(text)
853
+ text.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
854
+ end
855
+
814
856
  def build_default_input_bar
815
857
  cfg = safe_config
816
858
  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
@@ -51,8 +51,14 @@ module Legion
51
51
  FileUtils.rm_f(path)
52
52
  end
53
53
 
54
- def auto_session_name
55
- "auto-#{Time.now.strftime('%Y%m%d-%H%M%S')}"
54
+ def auto_session_name(messages: [])
55
+ first_user = messages.find { |m| m[:role] == :user }
56
+ return "session-#{Time.now.strftime('%H%M%S')}" unless first_user
57
+
58
+ words = first_user[:content].to_s.downcase.gsub(/[^a-z0-9\s]/, '').split
59
+ slug = words.first(4).join('-')
60
+ slug = "session-#{Time.now.strftime('%H%M%S')}" if slug.empty?
61
+ slug
56
62
  end
57
63
 
58
64
  private
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.6'
5
+ VERSION = '0.4.8'
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.6
4
+ version: 0.4.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity