legion-tty 0.4.17 → 0.4.19

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: 764baccaffa8d9863f8cb85248c15775fce561f729b74d70ff0b597f343eb0f5
4
- data.tar.gz: 39cacbd736ae3aacbeb54c429428c55cbde2a9325654f6d32e3e272c60586f7e
3
+ metadata.gz: 34931409185817b874f6234bb8e81b3fb9144764b53273b990e6c64168f0f087
4
+ data.tar.gz: 84e0e03f8dffb27b927792c5d53d49843206fc9a0b88a859b5f6cab190df29e0
5
5
  SHA512:
6
- metadata.gz: f91d4641d6ca7566d9229c1ae644b820f7963793a5a93d92be56547d178f5c205ddcf28ed2b0303fd545c86c85bfeda99f1ebe226392940e748f504a6045868c
7
- data.tar.gz: 03c2a6d201175628179ad926a6453c9446bb1bcdfd999dafafa38dc59aa0a0a422ebe654a89b8751f5f745e45a2c51ca28bf84ee9ba60c368120b9f13e6b6e97
6
+ metadata.gz: b2005ab12dbb4159e9ec13118de48de9a3dfc3b555f3bceb3ccde24cf4d2466836d33d157eb2558b3303f5e61b597e3894bdb910fe9d043cc2057f6b53647392
7
+ data.tar.gz: b9db6191d5b9425f1fa2eb16b222bf96937d702439432199a948a56b44a14a2b6826e14f5f49ca570885b3f3dd91dd3e56c5de378a337d8b719e887f62134823
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.19] - 2026-03-19
4
+
5
+ ### Added
6
+ - `/chain` command: send a sequence of pipe-separated prompts to the LLM sequentially
7
+ - `/info` command: comprehensive session info (modes, counts, aliases, snippets, macros, provider)
8
+ - `/scroll [top|bottom|N]` command: navigate to specific scroll position in message stream
9
+ - `/summary` command: generate a local conversation summary (topics, lengths, duration)
10
+
11
+ ## [0.4.18] - 2026-03-19
12
+
13
+ ### Added
14
+ - `/focus` command: toggle minimal UI mode (hides status bar for distraction-free writing)
15
+ - `/retry` command: resend last user message to LLM, replacing previous assistant response
16
+ - `/merge <session>` command: import messages from another saved session into current conversation
17
+ - `/sort [length|role]` command: display messages sorted by character length or grouped by role
18
+
3
19
  ## [0.4.17] - 2026-03-19
4
20
 
5
21
  ### Added
data/README.md CHANGED
@@ -2,15 +2,15 @@
2
2
 
3
3
  Rich terminal UI for the LegionIO async cognition engine.
4
4
 
5
- **Version**: 0.4.12
5
+ **Version**: 0.4.18
6
6
 
7
- Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell with 40 slash commands, operational dashboard, extensions browser, config editor, and session persistence - all rendered with the [tty-ruby](https://ttytoolkit.org/) gem ecosystem.
7
+ Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell with 60 slash commands, operational dashboard, extensions browser, config editor, and session persistence - all rendered with the [tty-ruby](https://ttytoolkit.org/) gem ecosystem.
8
8
 
9
9
  ## Features
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 40 slash commands, tab completion, markdown rendering, and tool panels
13
+ - **AI chat shell** - Streaming LLM chat with 60 slash commands, tab completion, markdown rendering, and tool panels
14
14
  - **Operational dashboard** - Service/LLM status, extension inventory, system info, panel navigation (Ctrl+D or `/dashboard`)
15
15
  - **Extensions browser** - Browse installed LEX gems by category with detail view and homepage opener ('o' key)
16
16
  - **Config viewer/editor** - View and edit `~/.legionio/settings/*.json` with vault:// masking and JSON validation
@@ -115,6 +115,25 @@ legion chat prompt "explain async cognition"
115
115
  | `/uptime` | Show session elapsed time |
116
116
  | `/bookmark` | Export pinned messages to file |
117
117
  | `/time` | Show current date and time |
118
+ | `/autosave [N\|off]` | Toggle periodic auto-save with interval |
119
+ | `/react <emoji>` | Add emoji reaction to a message |
120
+ | `/macro <action>` | Record/stop/play/list/delete command macros |
121
+ | `/tag <label>` | Tag a message with a label |
122
+ | `/tags [label]` | Show tag statistics or filter by tag |
123
+ | `/repeat` | Re-execute the last slash command |
124
+ | `/count <pattern>` | Count messages matching a pattern |
125
+ | `/template [name]` | List or use prompt templates |
126
+ | `/fav [N]` | Favorite a message (persists to disk) |
127
+ | `/favs` | Show all favorited messages |
128
+ | `/log [N]` | View last N lines of boot log |
129
+ | `/version` | Show version and platform info |
130
+ | `/focus` | Toggle minimal UI (hide status bar) |
131
+ | `/retry` | Resend last message to LLM |
132
+ | `/merge <session>` | Merge another session into current |
133
+ | `/sort [length\|role]` | Show messages sorted by length or role |
134
+ | `/import <path>` | Import session from a JSON file |
135
+ | `/mute` | Toggle system message display |
136
+ | `/wc` | Show word count statistics |
118
137
 
119
138
  ## Hotkeys
120
139
 
@@ -139,7 +158,7 @@ legion-tty
139
158
 
140
159
  Screens/
141
160
  Onboarding # First-run wizard (rain -> intro -> wizard -> reveal)
142
- Chat # AI chat REPL with streaming + 40 slash commands
161
+ Chat # AI chat REPL with streaming + 60 slash commands
143
162
  SessionCommands # save/load/sessions/delete/rename
144
163
  ExportCommands # export/bookmark/html/json/markdown
145
164
  MessageCommands # compact/copy/diff/search/grep/undo/pin/pins
@@ -194,8 +213,8 @@ Boot logs go to `~/.legionio/logs/tty-boot.log`.
194
213
 
195
214
  ```bash
196
215
  bundle install
197
- bundle exec rspec # 836 examples, 0 failures
198
- bundle exec rubocop # 92 files, 0 offenses
216
+ bundle exec rspec # 1143 examples, 0 failures
217
+ bundle exec rubocop # 106 files, 0 offenses
199
218
  ```
200
219
 
201
220
  ## License
@@ -280,6 +280,42 @@ module Legion
280
280
  @message_stream.add_message(role: :system, content: "Snippet '#{name}' not found.")
281
281
  end
282
282
  end
283
+
284
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
285
+ def handle_chain(input)
286
+ args = input.split(nil, 2)[1]
287
+ unless args
288
+ @message_stream.add_message(
289
+ role: :system,
290
+ content: 'Usage: /chain prompt1 | prompt2 | prompt3'
291
+ )
292
+ return :handled
293
+ end
294
+
295
+ unless @llm_chat || daemon_available?
296
+ @message_stream.add_message(role: :system, content: 'LLM not configured. Cannot run chain.')
297
+ return :handled
298
+ end
299
+
300
+ prompts = args.split('|').map(&:strip).reject(&:empty?)
301
+ if prompts.empty?
302
+ @message_stream.add_message(role: :system, content: 'Usage: /chain prompt1 | prompt2 | prompt3')
303
+ return :handled
304
+ end
305
+
306
+ prompts.each do |prompt|
307
+ @message_stream.add_message(role: :user, content: prompt)
308
+ @message_stream.add_message(role: :assistant, content: '')
309
+ send_to_llm(prompt)
310
+ end
311
+
312
+ @message_stream.add_message(
313
+ role: :system,
314
+ content: "Chain complete: #{prompts.size} prompt#{'s' unless prompts.size == 1} sent."
315
+ )
316
+ :handled
317
+ end
318
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
283
319
  end
284
320
  # rubocop:enable Metrics/ModuleLength
285
321
  end
@@ -299,6 +299,54 @@ module Legion
299
299
  end
300
300
  end
301
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
+
302
350
  def favorites_file
303
351
  File.expand_path('~/.legionio/favorites.json')
304
352
  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,83 @@ module Legion
156
156
  rescue StandardError
157
157
  nil
158
158
  end
159
+
160
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
161
+ def handle_info
162
+ cfg = safe_config
163
+ elapsed = Time.now - @session_start
164
+ hours = (elapsed / 3600).to_i
165
+ minutes = ((elapsed % 3600) / 60).to_i
166
+ seconds = (elapsed % 60).to_i
167
+ uptime_str = "#{hours}h #{minutes}m #{seconds}s"
168
+
169
+ msgs = @message_stream.messages
170
+ counts = %i[user assistant system tool].to_h { |r| [r, msgs.count { |m| m[:role] == r }] }
171
+ total_chars = msgs.sum { |m| m[:content].to_s.length }
172
+ avg_len = (total_chars.to_f / [msgs.size, 1].max).round
173
+
174
+ model_info = if @llm_chat.respond_to?(:model)
175
+ @llm_chat.model.to_s
176
+ else
177
+ cfg[:provider] || 'none'
178
+ end
179
+
180
+ tagged_count = msgs.count { |m| m[:tags]&.any? }
181
+ fav_count = msgs.count { |m| m[:favorited] }
182
+
183
+ lines = [
184
+ "Session: #{@session_name}",
185
+ "Started: #{@session_start.strftime('%Y-%m-%d %H:%M:%S')}",
186
+ "Uptime: #{uptime_str}",
187
+ '',
188
+ "Messages: #{msgs.size} total",
189
+ " User: #{counts[:user]}, Assistant: #{counts[:assistant]}, System: #{counts[:system]}",
190
+ " Tool: #{counts[:tool]}",
191
+ '',
192
+ "Total characters: #{total_chars}",
193
+ "Avg message length: #{avg_len} chars",
194
+ '',
195
+ "Pinned: #{@pinned_messages.size}",
196
+ "Tagged: #{tagged_count}",
197
+ "Favorited: #{fav_count}",
198
+ "Aliases: #{@aliases.size}",
199
+ "Snippets: #{@snippets.size}",
200
+ "Macros: #{@macros.size}",
201
+ '',
202
+ "Autosave: #{@autosave_enabled ? "ON (every #{@autosave_interval}s)" : 'OFF'}",
203
+ "Focus mode: #{@focus_mode ? 'on' : 'off'}",
204
+ "Muted system: #{@muted_system ? 'on' : 'off'}",
205
+ "Plan mode: #{@plan_mode ? 'on' : 'off'}",
206
+ "Debug mode: #{@debug_mode ? 'on' : 'off'}",
207
+ "LLM: #{model_info}"
208
+ ]
209
+ @message_stream.add_message(role: :system, content: lines.join("\n"))
210
+ :handled
211
+ end
212
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
213
+
214
+ def handle_merge(input)
215
+ name = input.split(nil, 2)[1]
216
+ unless name
217
+ @message_stream.add_message(role: :system, content: 'Usage: /merge <session-name>')
218
+ return :handled
219
+ end
220
+
221
+ data = @session_store.load(name)
222
+ unless data
223
+ @message_stream.add_message(role: :system, content: 'Session not found.')
224
+ return :handled
225
+ end
226
+
227
+ imported = data[:messages]
228
+ @message_stream.messages.concat(imported)
229
+ @status_bar.update(message_count: @message_stream.messages.size)
230
+ @message_stream.add_message(
231
+ role: :system,
232
+ content: "Merged #{imported.size} messages from '#{name}'."
233
+ )
234
+ :handled
235
+ end
159
236
  end
160
237
  # rubocop:enable Metrics/ModuleLength
161
238
  end
@@ -303,6 +303,88 @@ module Legion
303
303
  )
304
304
  :handled
305
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
316
+
317
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
318
+ def handle_scroll(input)
319
+ arg = input.split(nil, 2)[1]
320
+ unless arg
321
+ pos = @message_stream.scroll_position
322
+ @message_stream.add_message(
323
+ role: :system,
324
+ content: "Scroll position: offset=#{pos[:current]}, messages=#{pos[:total]}"
325
+ )
326
+ return :handled
327
+ end
328
+
329
+ case arg.strip
330
+ when 'top'
331
+ @message_stream.scroll_up(@message_stream.messages.size * 5)
332
+ @message_stream.add_message(role: :system, content: 'Scrolled to top.')
333
+ when 'bottom'
334
+ @message_stream.scroll_down(@message_stream.scroll_offset)
335
+ @message_stream.add_message(role: :system, content: 'Scrolled to bottom.')
336
+ else
337
+ idx = arg.strip.to_i
338
+ if idx >= 0 && idx < @message_stream.messages.size
339
+ @message_stream.scroll_down(@message_stream.scroll_offset)
340
+ target_offset = [@message_stream.messages.size - idx - 1, 0].max
341
+ @message_stream.scroll_up(target_offset)
342
+ @message_stream.add_message(role: :system, content: "Scrolled to message #{idx}.")
343
+ else
344
+ @message_stream.add_message(
345
+ role: :system,
346
+ content: 'Invalid index. Usage: /scroll top|bottom|<N>'
347
+ )
348
+ end
349
+ end
350
+ :handled
351
+ end
352
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
353
+
354
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
355
+ def handle_summary
356
+ msgs = @message_stream.messages
357
+ elapsed = Time.now - @session_start
358
+ hours = (elapsed / 3600).to_i
359
+ minutes = ((elapsed % 3600) / 60).to_i
360
+ seconds = (elapsed % 60).to_i
361
+ uptime_str = "#{hours}h #{minutes}m #{seconds}s"
362
+
363
+ counts = %i[user assistant system].to_h { |r| [r, msgs.count { |m| m[:role] == r }] }
364
+ most_active = counts.max_by { |_, v| v }&.first || :none
365
+
366
+ user_msgs = msgs.select { |m| m[:role] == :user }
367
+ top_words = user_msgs.flat_map { |m| m[:content].to_s.split.first(1) }
368
+ .tally.sort_by { |_, c| -c }.first(5).map(&:first)
369
+
370
+ longest = msgs.max_by { |m| m[:content].to_s.length }
371
+ longest_preview = longest ? truncate_text(longest[:content].to_s, 60) : 'none'
372
+
373
+ last_user = user_msgs.last
374
+ recent_topic = last_user ? truncate_text(last_user[:content].to_s, 40) : 'none'
375
+
376
+ lines = [
377
+ 'Conversation Summary',
378
+ " Messages: #{msgs.size}, Duration: #{uptime_str}",
379
+ " Most active role: #{most_active}",
380
+ " Top starting words: #{top_words.empty? ? 'none' : top_words.join(', ')}",
381
+ " Longest message: #{longest_preview}",
382
+ " Most recent topic: #{recent_topic}"
383
+ ]
384
+ @message_stream.add_message(role: :system, content: lines.join("\n"))
385
+ :handled
386
+ end
387
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
306
388
  end
307
389
  # rubocop:enable Metrics/ModuleLength
308
390
  end
@@ -30,7 +30,9 @@ module Legion
30
30
  /theme /search /grep /stats /personality /undo /history /pin /pins /rename
31
31
  /context /alias /snippet /debug /uptime /time /bookmark /welcome /tips
32
32
  /wc /import /mute /autosave /react /macro /tag /tags /repeat /count
33
- /template /fav /favs /log /version].freeze
33
+ /template /fav /favs /log /version
34
+ /focus /retry /merge /sort
35
+ /chain /info /scroll /summary].freeze
34
36
 
35
37
  PERSONALITIES = {
36
38
  'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
@@ -68,6 +70,8 @@ module Legion
68
70
  @recording_macro = nil
69
71
  @macro_buffer = []
70
72
  @last_command = nil
73
+ @focus_mode = false
74
+ @last_user_input = nil
71
75
  end
72
76
 
73
77
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -131,6 +135,7 @@ module Legion
131
135
  end
132
136
 
133
137
  def handle_user_message(input)
138
+ @last_user_input = input
134
139
  @message_stream.add_message(role: :user, content: input)
135
140
  if @plan_mode
136
141
  @message_stream.add_message(role: :system, content: '(bookmarked)')
@@ -160,16 +165,9 @@ module Legion
160
165
  end
161
166
 
162
167
  def render(width, height)
163
- bar_line = @status_bar.render(width: width)
164
- divider = Theme.c(:muted, '-' * width)
165
- dbg = debug_segment
166
- extra_rows = dbg ? 1 : 0
167
- stream_height = [height - 2 - extra_rows, 1].max
168
- stream_lines = @message_stream.render(width: width, height: stream_height)
169
- @status_bar.update(scroll: @message_stream.scroll_position)
170
- lines = stream_lines + [divider, bar_line]
171
- lines << dbg if dbg
172
- lines
168
+ return render_focus(width, height) if @focus_mode
169
+
170
+ render_normal(width, height)
173
171
  end
174
172
 
175
173
  def handle_input(key)
@@ -187,6 +185,25 @@ module Legion
187
185
 
188
186
  private
189
187
 
188
+ def render_focus(width, height)
189
+ stream_lines = @message_stream.render(width: width, height: [height, 1].max)
190
+ @status_bar.update(scroll: @message_stream.scroll_position)
191
+ stream_lines
192
+ end
193
+
194
+ def render_normal(width, height)
195
+ bar_line = @status_bar.render(width: width)
196
+ divider = Theme.c(:muted, '-' * width)
197
+ dbg = debug_segment
198
+ extra_rows = dbg ? 1 : 0
199
+ stream_height = [height - 2 - extra_rows, 1].max
200
+ stream_lines = @message_stream.render(width: width, height: stream_height)
201
+ @status_bar.update(scroll: @message_stream.scroll_position)
202
+ lines = stream_lines + [divider, bar_line]
203
+ lines << dbg if dbg
204
+ lines
205
+ end
206
+
190
207
  def record_macro_step(input, cmd, result)
191
208
  return unless @recording_macro
192
209
  return if cmd == '/macro'
@@ -385,6 +402,14 @@ module Legion
385
402
  when '/favs' then handle_favs
386
403
  when '/log' then handle_log(input)
387
404
  when '/version' then handle_version
405
+ when '/focus' then handle_focus
406
+ when '/retry' then handle_retry
407
+ when '/merge' then handle_merge(input)
408
+ when '/sort' then handle_sort(input)
409
+ when '/chain' then handle_chain(input)
410
+ when '/info' then handle_info
411
+ when '/scroll' then handle_scroll(input)
412
+ when '/summary' then handle_summary
388
413
  else :handled
389
414
  end
390
415
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.17'
5
+ VERSION = '0.4.19'
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.17
4
+ version: 0.4.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity