legion-tty 0.4.26 → 0.4.27

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: 8afc2aa24b5ae5e56f84d7f7ff538e9d0ccc0b9274fd983cec26116fb234b774
4
- data.tar.gz: 2d442f8ca2ec7a2624f4f3de63defb569c3b324babfff9e8f98c9540bef984d3
3
+ metadata.gz: f1f14c29bb844005733b887586662c8c08947f757567ec9438029b423b0c991e
4
+ data.tar.gz: 3e0244816b27e6174541fceba9aea3df6d87a7698f40766cf7341cf12b8ab0b0
5
5
  SHA512:
6
- metadata.gz: f1cd31e1a7fff97bd1de1088247fa1c195c27beaaef7f7d94df5e0ebaf6e46bca7c31a55c2321956c9fbbb46a88e862b6db6139f5df54b104785e1061766b37e
7
- data.tar.gz: f38bec5c06b804f6b74fc103804a7b46cb9a9b762ec73c4eac3409d0ea72db235b69638b93cd8342a9052d4a5eb20399498b05e0cda59a303c911c4b08e734f2
6
+ metadata.gz: 3af49d57dacb01af6ffa504d2e547d0ca920e0b675c119cdfdf95bf6f5926e5ba3f212144e3f139c005929abd52c147fa57dc06848aaae0a098a2444b84db20a
7
+ data.tar.gz: cb4b0902b3c3669231ff0ffc9bbe7b198f334fa8f3f68454a79345f808313c4f13ad63ea3c01efb36225beac388b84b28dc8c2a3d2558aead4f8c0a61acb69e6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.27] - 2026-03-19
4
+
5
+ ### Added
6
+ - `/transform <op>` command: apply string transformations (upcase/downcase/reverse/strip/squeeze) to last assistant message
7
+ - `/concat` command: concatenate all assistant messages into a single system message
8
+ - `/split <N> [pattern]` command: split a message at index N by pattern (default: paragraph break)
9
+ - `/swap <A> <B>` command: swap two messages by index
10
+ - `/prefix [text|clear]` command: set/show/clear auto-prefix for outgoing messages
11
+ - `/suffix [text|clear]` command: set/show/clear auto-suffix for outgoing messages
12
+ - `/timer <seconds> [message] | cancel` command: countdown timer with background thread and status bar notification
13
+ - `/notify <message>` command: send a manual toast notification to status bar
14
+ - `apply_message_decorators` method: prepends prefix and appends suffix to user messages before LLM send
15
+
16
+ ### Changed
17
+ - Total slash commands: 115
18
+ - Total specs: 1817 examples, 150 files
19
+
3
20
  ## [0.4.26] - 2026-03-19
4
21
 
5
22
  ### 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.25
5
+ **Version**: 0.4.27
6
6
 
7
- Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell with 103 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 115 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 103 slash commands, tab completion, markdown rendering, and tool panels
13
+ - **AI chat shell** - Streaming LLM chat with 115 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
@@ -198,6 +198,18 @@ legion chat prompt "explain async cognition"
198
198
  | `/wc` | Show word count statistics per role |
199
199
  | `/welcome` | Redisplay the welcome message |
200
200
  | `/wrap [N\|off]` | Set custom word wrap width |
201
+ | `/ago <N>` | Show what was said N messages ago |
202
+ | `/concat` | Concatenate all assistant messages into one |
203
+ | `/goto <N>` | Jump to specific message by index |
204
+ | `/inject <role> <text>` | Inject a message with specific role |
205
+ | `/notify <message>` | Send a toast notification to status bar |
206
+ | `/prefix [text\|clear]` | Set/show/clear auto-prefix for outgoing messages |
207
+ | `/split <N> [pattern]` | Split a message by pattern into multiple messages |
208
+ | `/stopwatch [start\|stop\|lap\|reset]` | Built-in stopwatch with MM:SS.ms format |
209
+ | `/suffix [text\|clear]` | Set/show/clear auto-suffix for outgoing messages |
210
+ | `/swap <A> <B>` | Swap two messages by index |
211
+ | `/timer <seconds> [message]` | Countdown timer with notification on expiry |
212
+ | `/transform <op>` | Apply string transformation to last assistant message |
201
213
 
202
214
  ## Hotkeys
203
215
 
@@ -222,13 +234,13 @@ legion-tty
222
234
 
223
235
  Screens/
224
236
  Onboarding # First-run wizard (rain -> intro -> wizard -> reveal)
225
- Chat # AI chat REPL with streaming + 103 slash commands
237
+ Chat # AI chat REPL with streaming + 115 slash commands
226
238
  SessionCommands # save/load/sessions/delete/rename/import/merge/autosave
227
239
  ExportCommands # export/bookmark/html/json/markdown/yaml
228
- MessageCommands # compact/copy/diff/search/grep/undo/pin/pins/react/tag/fav/sort/count
229
- UiCommands # help/clear/dashboard/hotkeys/palette/context/stats/debug/history/uptime/time/tips/welcome/focus/wc/log/version/mute + calc/rand/mark/freq/color/timestamps/top/bottom/head/tail/echo/env/speak/silent/wrap/number/truncate/about/commands/ask/define/status/prefs
240
+ MessageCommands # compact/copy/diff/search/grep/undo/pin/pins/react/tag/fav/sort/count/transform/concat/split/swap
241
+ UiCommands # help/clear/dashboard/hotkeys/palette/context/stats/debug/history/uptime/time/tips/welcome/focus/wc/log/version/mute + calc/rand/mark/freq/color/timestamps/top/bottom/head/tail/echo/env/speak/silent/wrap/number/truncate/about/commands/ask/define/status/prefs/timer/notify
230
242
  ModelCommands # model/system/personality switching/retry/chain/info/scroll/summary/prompt/reset/replace/highlight/multiline/filter/annotate/annotations
231
- CustomCommands # alias/snippet/template/macro/draft/revise/tee/pipe/archive/archives/ls/pwd/rand/calc
243
+ CustomCommands # alias/snippet/template/macro/draft/revise/tee/pipe/archive/archives/ls/pwd/prefix/suffix
232
244
  Dashboard # Service/LLM status, panel navigation (j/k/1-5)
233
245
  Extensions # LEX gem browser by category with homepage opener
234
246
  Config # Settings file viewer/editor with JSON validation
@@ -277,8 +289,8 @@ Boot logs go to `~/.legionio/logs/tty-boot.log`.
277
289
 
278
290
  ```bash
279
291
  bundle install
280
- bundle exec rspec # 1687 examples, 0 failures
281
- bundle exec rubocop # 143 files, 0 offenses
292
+ bundle exec rspec # 1817 examples, 0 failures
293
+ bundle exec rubocop # 150 files, 0 offenses
282
294
  ```
283
295
 
284
296
  ## License
@@ -408,6 +408,42 @@ module Legion
408
408
  :handled
409
409
  end
410
410
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
411
+
412
+ def handle_prefix(input)
413
+ arg = input.split(nil, 2)[1]
414
+ if arg.nil?
415
+ if @message_prefix
416
+ @message_stream.add_message(role: :system, content: "Current prefix: \"#{@message_prefix}\"")
417
+ else
418
+ @message_stream.add_message(role: :system, content: 'No prefix set. Usage: /prefix <text>')
419
+ end
420
+ elsif arg == 'clear'
421
+ @message_prefix = nil
422
+ @message_stream.add_message(role: :system, content: 'Prefix cleared.')
423
+ else
424
+ @message_prefix = arg
425
+ @message_stream.add_message(role: :system, content: "Prefix set: \"#{@message_prefix}\"")
426
+ end
427
+ :handled
428
+ end
429
+
430
+ def handle_suffix(input)
431
+ arg = input.split(nil, 2)[1]
432
+ if arg.nil?
433
+ if @message_suffix
434
+ @message_stream.add_message(role: :system, content: "Current suffix: \"#{@message_suffix}\"")
435
+ else
436
+ @message_stream.add_message(role: :system, content: 'No suffix set. Usage: /suffix <text>')
437
+ end
438
+ elsif arg == 'clear'
439
+ @message_suffix = nil
440
+ @message_stream.add_message(role: :system, content: 'Suffix cleared.')
441
+ else
442
+ @message_suffix = arg
443
+ @message_stream.add_message(role: :system, content: "Suffix set: \"#{@message_suffix}\"")
444
+ end
445
+ :handled
446
+ end
411
447
  end
412
448
  end
413
449
  end
@@ -6,6 +6,7 @@ module Legion
6
6
  class Chat < Base
7
7
  module MessageCommands
8
8
  INJECT_VALID_ROLES = %w[user assistant system].freeze
9
+ TRANSFORM_OPS = %w[upcase downcase reverse strip squeeze].freeze
9
10
 
10
11
  private
11
12
 
@@ -677,6 +678,43 @@ module Legion
677
678
  :handled
678
679
  end
679
680
 
681
+ def handle_transform(input)
682
+ op = input.split(nil, 2)[1]
683
+ unless op && TRANSFORM_OPS.include?(op)
684
+ @message_stream.add_message(
685
+ role: :system,
686
+ content: "Usage: /transform <#{TRANSFORM_OPS.join('|')}>"
687
+ )
688
+ return :handled
689
+ end
690
+
691
+ msg = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
692
+ unless msg
693
+ @message_stream.add_message(role: :system, content: 'No assistant message to transform.')
694
+ return :handled
695
+ end
696
+
697
+ msg[:content] = msg[:content].to_s.send(op)
698
+ @message_stream.add_message(role: :system, content: "Transformed last assistant message: #{op}.")
699
+ :handled
700
+ end
701
+
702
+ def handle_concat
703
+ assistant_msgs = @message_stream.messages.select { |m| m[:role] == :assistant }
704
+ if assistant_msgs.empty?
705
+ @message_stream.add_message(role: :system, content: 'No assistant messages to concatenate.')
706
+ return :handled
707
+ end
708
+
709
+ combined = assistant_msgs.map { |m| m[:content].to_s }.join("\n\n")
710
+ @message_stream.add_message(role: :system, content: combined)
711
+ @message_stream.add_message(
712
+ role: :system,
713
+ content: "Concatenated #{assistant_msgs.size} assistant message(s)."
714
+ )
715
+ :handled
716
+ end
717
+
680
718
  def handle_ago(input)
681
719
  n = (input.split(nil, 2)[1] || '1').to_i
682
720
  msgs = @message_stream.messages
@@ -697,6 +735,72 @@ module Legion
697
735
  )
698
736
  :handled
699
737
  end
738
+
739
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
740
+ def handle_split(input)
741
+ parts = input.split(nil, 3)
742
+ n_str = parts[1]
743
+ pattern_str = parts[2]
744
+
745
+ unless n_str&.match?(/\A\d+\z/)
746
+ @message_stream.add_message(role: :system, content: 'Usage: /split <N> [pattern]')
747
+ return :handled
748
+ end
749
+
750
+ idx = n_str.to_i
751
+ msgs = @message_stream.messages
752
+ unless idx < msgs.size
753
+ @message_stream.add_message(role: :system,
754
+ content: "No message at index #{idx} (#{msgs.size} message(s)).")
755
+ return :handled
756
+ end
757
+
758
+ pattern = pattern_str || "\n\n"
759
+ original = msgs[idx]
760
+ segments = original[:content].to_s.split(pattern).reject(&:empty?)
761
+
762
+ if segments.size <= 1
763
+ @message_stream.add_message(role: :system, content: 'Message could not be split (no pattern found).')
764
+ return :handled
765
+ end
766
+
767
+ new_msgs = segments.map { |seg| { role: original[:role], content: seg } }
768
+ msgs.delete_at(idx)
769
+ msgs.insert(idx, *new_msgs)
770
+ @status_bar.update(message_count: msgs.size)
771
+ @message_stream.add_message(role: :system, content: "Split into #{new_msgs.size} messages.")
772
+ :handled
773
+ end
774
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
775
+
776
+ # rubocop:disable Metrics/AbcSize
777
+ def handle_swap(input)
778
+ parts = input.split(nil, 3)
779
+ a_str = parts[1]
780
+ b_str = parts[2]
781
+
782
+ unless a_str&.match?(/\A\d+\z/) && b_str&.match?(/\A\d+\z/)
783
+ @message_stream.add_message(role: :system, content: 'Usage: /swap <A> <B>')
784
+ return :handled
785
+ end
786
+
787
+ a = a_str.to_i
788
+ b = b_str.to_i
789
+ msgs = @message_stream.messages
790
+
791
+ if a >= msgs.size || b >= msgs.size
792
+ @message_stream.add_message(
793
+ role: :system,
794
+ content: "Index out of range (conversation has #{msgs.size} message(s))."
795
+ )
796
+ return :handled
797
+ end
798
+
799
+ msgs[a], msgs[b] = msgs[b], msgs[a]
800
+ @message_stream.add_message(role: :system, content: "Swapped messages #{a} and #{b}.")
801
+ :handled
802
+ end
803
+ # rubocop:enable Metrics/AbcSize
700
804
  end
701
805
  end
702
806
  end
@@ -944,6 +944,78 @@ module Legion
944
944
  mins = total_ms / 60_000
945
945
  format('%<mins>02d:%<secs>02d.%<ms>03d', mins: mins, secs: secs, ms: ms)
946
946
  end
947
+
948
+ def handle_timer(input)
949
+ arg = input.split(nil, 2)[1]&.strip
950
+
951
+ return timer_status if arg.nil? || arg.empty?
952
+
953
+ return timer_cancel if arg == 'cancel'
954
+
955
+ seconds_str, *msg_parts = arg.split
956
+ unless seconds_str.match?(/\A\d+\z/)
957
+ @message_stream.add_message(role: :system, content: 'Usage: /timer <seconds> [message] | cancel')
958
+ return :handled
959
+ end
960
+
961
+ seconds = seconds_str.to_i
962
+ message = msg_parts.empty? ? 'Timer expired!' : msg_parts.join(' ')
963
+ start_timer(seconds, message)
964
+ end
965
+
966
+ def handle_notify(input)
967
+ text = input.split(nil, 2)[1]&.strip
968
+ unless text && !text.empty?
969
+ @message_stream.add_message(role: :system, content: 'Usage: /notify <message>')
970
+ return :handled
971
+ end
972
+
973
+ @status_bar.notify(message: text, level: :info, ttl: 5)
974
+ :handled
975
+ end
976
+
977
+ def timer_status
978
+ if @timer_thread&.alive?
979
+ remaining = @timer_end - Time.now
980
+ remaining = [remaining, 0].max.ceil
981
+ @message_stream.add_message(role: :system, content: "Timer running: #{remaining}s remaining.")
982
+ else
983
+ @message_stream.add_message(role: :system, content: 'No active timer.')
984
+ end
985
+ :handled
986
+ end
987
+
988
+ def timer_cancel
989
+ if @timer_thread&.alive?
990
+ @timer_thread.kill
991
+ @timer_thread = nil
992
+ @timer_end = nil
993
+ @message_stream.add_message(role: :system, content: 'Timer cancelled.')
994
+ else
995
+ @message_stream.add_message(role: :system, content: 'No active timer to cancel.')
996
+ end
997
+ :handled
998
+ end
999
+
1000
+ def start_timer(seconds, message)
1001
+ if @timer_thread&.alive?
1002
+ @message_stream.add_message(role: :system,
1003
+ content: 'A timer is already running. Use /timer cancel first.')
1004
+ return :handled
1005
+ end
1006
+
1007
+ @timer_end = Time.now + seconds
1008
+ @message_stream.add_message(role: :system, content: "Timer set for #{seconds}s: #{message}")
1009
+ @status_bar.notify(message: "Timer: #{seconds}s", level: :info, ttl: 3)
1010
+ @timer_thread = Thread.new do
1011
+ sleep(seconds)
1012
+ @message_stream.add_message(role: :system, content: "Timer: #{message}")
1013
+ @status_bar.notify(message: message, level: :info, ttl: 10)
1014
+ @timer_thread = nil
1015
+ @timer_end = nil
1016
+ end
1017
+ :handled
1018
+ end
947
1019
  end
948
1020
  end
949
1021
  end
@@ -50,7 +50,11 @@ module Legion
50
50
  /ask /define
51
51
  /status /prefs
52
52
  /stopwatch /ago
53
- /goto /inject].freeze
53
+ /goto /inject
54
+ /transform /concat
55
+ /prefix /suffix
56
+ /split /swap
57
+ /timer /notify].freeze
54
58
 
55
59
  PERSONALITIES = {
56
60
  'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
@@ -97,6 +101,9 @@ module Legion
97
101
  @draft = nil
98
102
  @stopwatch_start = nil
99
103
  @stopwatch_elapsed = 0
104
+ @timer_thread = nil
105
+ @message_prefix = nil
106
+ @message_suffix = nil
100
107
  end
101
108
 
102
109
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -167,7 +174,7 @@ module Legion
167
174
  @message_stream.add_message(role: :system, content: '(bookmarked)')
168
175
  else
169
176
  @message_stream.add_message(role: :assistant, content: '')
170
- send_to_llm(input)
177
+ send_to_llm(apply_message_decorators(input))
171
178
  end
172
179
  @status_bar.update(message_count: @message_stream.messages.size)
173
180
  check_autosave
@@ -509,6 +516,14 @@ module Legion
509
516
  when '/ago' then handle_ago(input)
510
517
  when '/goto' then handle_goto(input)
511
518
  when '/inject' then handle_inject(input)
519
+ when '/transform' then handle_transform(input)
520
+ when '/concat' then handle_concat
521
+ when '/prefix' then handle_prefix(input)
522
+ when '/suffix' then handle_suffix(input)
523
+ when '/split' then handle_split(input)
524
+ when '/swap' then handle_swap(input)
525
+ when '/timer' then handle_timer(input)
526
+ when '/notify' then handle_notify(input)
512
527
  else :handled
513
528
  end
514
529
  end
@@ -644,6 +659,13 @@ module Legion
644
659
  cost: @token_tracker.total_cost
645
660
  )
646
661
  end
662
+
663
+ def apply_message_decorators(message)
664
+ result = message
665
+ result = "#{@message_prefix}#{result}" if @message_prefix
666
+ result = "#{result}#{@message_suffix}" if @message_suffix
667
+ result
668
+ end
647
669
  end
648
670
  # rubocop:enable Metrics/ClassLength
649
671
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.26'
5
+ VERSION = '0.4.27'
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.26
4
+ version: 0.4.27
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity