legion-tty 0.4.8 → 0.4.10

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: 49d5dd5b69b7fa3412226bc38e93b0adb0df6be7c3b0317955bf7f9698a0759d
4
- data.tar.gz: ed73f018bbb85ee31fdf11aba24f5b6f332f28ef85970f9189ba5164402d8690
3
+ metadata.gz: b4f6ee315fe6ff9851580bebff6f0187665a2c53f1719c48ad807879651eee44
4
+ data.tar.gz: 938e195e473fb65a27840f31b82cefa9032c9f500d828f2844d8ca58b7f9bc95
5
5
  SHA512:
6
- metadata.gz: 67a87dfd8538f4c42e5c9897d4460d8457071660e95df3fe019757d3e9e56ef95627d4a5a3576914611257f78fd766190909e4e95f8f4bee26b1c0328745c35f
7
- data.tar.gz: a6f9bd2210d04ad3d2200a4029e32141eb82b29043dee974cb5b37d5d6b927b1f2b284cac1d3e39c77c6105fa7c08f61a3da8a678e4d77e88de675cfe9cac2ea
6
+ metadata.gz: 27167cd635e065fca190930586ba383ffe8b8595c772eb917dd243aa7832b6d23807198e2e73b25b942154fb155c455e239c52e51b68ad16675beb363224be28
7
+ data.tar.gz: cdbcae2d8a592ea9b6316f3069f593c49b11f242ddfedd5306313a53b6bce48e5c718c06fb2cbb21e44f4c8b323a4fae06839b47fda2cb41bd8c7312a405be55
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.10] - 2026-03-19
4
+
5
+ ### Added
6
+ - `/context` command: display active session state summary (model, personality, plan mode, system prompt, session, message count, pinned count, token usage)
7
+ - `/alias` command: create short aliases for frequently used slash commands; aliases expand and re-dispatch transparently
8
+ - `/snippet save|load|list|delete <name>` command: save last assistant message as a named snippet, insert snippets as user messages, persist to `~/.legionio/snippets/`
9
+ - `/debug` command: toggle debug mode; adds `[DEBUG]` line to render output showing msgs/scroll/plan/personality/aliases/snippets/pinned counts; StatusBar shows `[DBG]` indicator
10
+
11
+ ## [0.4.9] - 2026-03-19
12
+
13
+ ### Added
14
+ - StatusBar notifications: transient toast-style messages with TTL expiry (wired to save/load/export/theme)
15
+ - `/undo` command: remove last user+assistant message pair
16
+ - `/history` command: show last 20 input entries
17
+ - `/pin` and `/pins` commands: pin important messages, view pinned list
18
+ - `/rename <name>` command: rename current session (deletes old, saves new)
19
+
3
20
  ## [0.4.8] - 2026-03-19
4
21
 
5
22
  ### Added
@@ -1,13 +1,20 @@
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
7
8
  module Components
8
9
  class StatusBar
9
10
  def initialize
10
- @state = { model: nil, tokens: 0, cost: 0.0, session: 'default', thinking: false, plan_mode: false }
11
+ @state = { model: nil, tokens: 0, cost: 0.0, session: 'default', thinking: false, plan_mode: false,
12
+ debug_mode: false }
13
+ @notifications = []
14
+ end
15
+
16
+ def notify(message:, level: :info, ttl: 5)
17
+ @notifications << Notification.new(message: message, level: level, ttl: ttl)
11
18
  end
12
19
 
13
20
  def update(**fields)
@@ -34,7 +41,9 @@ module Legion
34
41
  [
35
42
  model_segment,
36
43
  plan_segment,
44
+ debug_segment,
37
45
  thinking_segment,
46
+ notification_segment,
38
47
  tokens_segment,
39
48
  cost_segment,
40
49
  session_segment,
@@ -52,6 +61,12 @@ module Legion
52
61
  Theme.c(:warning, '[PLAN]')
53
62
  end
54
63
 
64
+ def debug_segment
65
+ return nil unless @state[:debug_mode]
66
+
67
+ Theme.c(:muted, '[DBG]')
68
+ end
69
+
55
70
  def thinking_segment
56
71
  return nil unless @state[:thinking]
57
72
 
@@ -60,6 +75,13 @@ module Legion
60
75
  Theme.c(:warning, "#{frame} thinking...")
61
76
  end
62
77
 
78
+ def notification_segment
79
+ @notifications.reject!(&:expired?)
80
+ return nil if @notifications.empty?
81
+
82
+ @notifications.first.render
83
+ end
84
+
63
85
  def tokens_segment
64
86
  Theme.c(:secondary, "#{format_number(@state[:tokens])} tokens") if @state[:tokens].to_i.positive?
65
87
  end
@@ -14,7 +14,8 @@ 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
18
+ /context /alias /snippet /debug].freeze
18
19
 
19
20
  PERSONALITIES = {
20
21
  'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
@@ -38,6 +39,10 @@ module Legion
38
39
  @session_store = SessionStore.new
39
40
  @session_name = 'default'
40
41
  @plan_mode = false
42
+ @pinned_messages = []
43
+ @aliases = {}
44
+ @snippets = {}
45
+ @debug_mode = false
41
46
  end
42
47
 
43
48
  def activate
@@ -84,7 +89,12 @@ module Legion
84
89
  return nil unless input.start_with?('/')
85
90
 
86
91
  cmd = input.split.first
87
- return nil unless SLASH_COMMANDS.include?(cmd)
92
+ unless SLASH_COMMANDS.include?(cmd)
93
+ expanded = @aliases[cmd]
94
+ return nil unless expanded
95
+
96
+ return handle_slash_command("#{expanded} #{input.split(nil, 2)[1]}".strip)
97
+ end
88
98
 
89
99
  dispatch_slash(cmd, input)
90
100
  end
@@ -119,10 +129,14 @@ module Legion
119
129
  def render(width, height)
120
130
  bar_line = @status_bar.render(width: width)
121
131
  divider = Theme.c(:muted, '-' * width)
122
- stream_height = [height - 2, 1].max
132
+ dbg = debug_segment
133
+ extra_rows = dbg ? 1 : 0
134
+ stream_height = [height - 2 - extra_rows, 1].max
123
135
  stream_lines = @message_stream.render(width: width, height: stream_height)
124
136
  @status_bar.update(scroll: @message_stream.scroll_position)
125
- stream_lines + [divider, bar_line]
137
+ lines = stream_lines + [divider, bar_line]
138
+ lines << dbg if dbg
139
+ lines
126
140
  end
127
141
 
128
142
  def handle_input(key)
@@ -263,7 +277,7 @@ module Legion
263
277
  nil
264
278
  end
265
279
 
266
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
280
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
267
281
  def dispatch_slash(cmd, input)
268
282
  case cmd
269
283
  when '/quit' then :quit
@@ -292,11 +306,21 @@ module Legion
292
306
  when '/search' then handle_search(input)
293
307
  when '/stats' then handle_stats
294
308
  when '/personality' then handle_personality(input)
309
+ when '/undo' then handle_undo
310
+ when '/history' then handle_history
311
+ when '/pin' then handle_pin(input)
312
+ when '/pins' then handle_pins
313
+ when '/rename' then handle_rename(input)
314
+ when '/context' then handle_context
315
+ when '/alias' then handle_alias(input)
316
+ when '/snippet' then handle_snippet(input)
317
+ when '/debug' then handle_debug
295
318
  else :handled
296
319
  end
297
320
  end
298
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
321
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
299
322
 
323
+ # rubocop:disable Metrics/MethodLength
300
324
  def handle_help
301
325
  @message_stream.add_message(
302
326
  role: :system,
@@ -309,11 +333,21 @@ module Legion
309
333
  "/copy -- copy last assistant message to clipboard\n " \
310
334
  "/diff -- show new messages since session was loaded\n " \
311
335
  "/stats -- show conversation statistics\n " \
312
- "/personality [name] -- switch assistant personality\n\n" \
336
+ "/personality [name] -- switch assistant personality\n " \
337
+ "/undo -- remove last user+assistant message pair\n " \
338
+ "/history -- show recent input history\n " \
339
+ "/pin [N] -- pin last assistant message (or message at index N)\n " \
340
+ "/pins -- show all pinned messages\n " \
341
+ "/rename <name> -- rename current session\n " \
342
+ "/context -- show active session context summary\n " \
343
+ "/alias [shortname /command] -- create or list command aliases\n " \
344
+ "/snippet save|load|list|delete <name> -- manage reusable text snippets\n " \
345
+ "/debug -- toggle debug mode (shows internal state)\n\n" \
313
346
  'Hotkeys: Ctrl+D=dashboard Ctrl+K=palette Ctrl+S=sessions Esc=back'
314
347
  )
315
348
  :handled
316
349
  end
350
+ # rubocop:enable Metrics/MethodLength
317
351
 
318
352
  def handle_clear
319
353
  @message_stream.messages.clear
@@ -403,6 +437,7 @@ module Legion
403
437
  @session_name = name
404
438
  @session_store.save(name, messages: @message_stream.messages)
405
439
  @status_bar.update(session: name)
440
+ @status_bar.notify(message: "Saved '#{name}'", level: :success, ttl: 3)
406
441
  @message_stream.add_message(role: :system, content: "Session saved as '#{name}'.")
407
442
  :handled
408
443
  end
@@ -422,6 +457,7 @@ module Legion
422
457
  @loaded_message_count = @message_stream.messages.size
423
458
  @session_name = name
424
459
  @status_bar.update(session: name)
460
+ @status_bar.notify(message: "Loaded '#{name}'", level: :info, ttl: 3)
425
461
  @message_stream.add_message(role: :system,
426
462
  content: "Session '#{name}' loaded (#{data[:messages].size} messages).")
427
463
  :handled
@@ -454,16 +490,29 @@ module Legion
454
490
  :handled
455
491
  end
456
492
 
457
- # rubocop:disable Metrics/AbcSize
458
493
  def handle_export(input)
459
494
  require 'fileutils'
495
+ path = build_export_path(input)
496
+ dispatch_export(path, input.split[1]&.downcase)
497
+ @status_bar.notify(message: 'Exported', level: :success, ttl: 3)
498
+ @message_stream.add_message(role: :system, content: "Exported to: #{path}")
499
+ :handled
500
+ rescue StandardError => e
501
+ @message_stream.add_message(role: :system, content: "Export failed: #{e.message}")
502
+ :handled
503
+ end
504
+
505
+ def build_export_path(input)
460
506
  format = input.split[1]&.downcase
461
507
  format = 'md' unless %w[json md html].include?(format)
462
508
  exports_dir = File.expand_path('~/.legionio/exports')
463
509
  FileUtils.mkdir_p(exports_dir)
464
510
  timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
465
511
  ext = { 'json' => 'json', 'md' => 'md', 'html' => 'html' }[format]
466
- path = File.join(exports_dir, "chat-#{timestamp}.#{ext}")
512
+ File.join(exports_dir, "chat-#{timestamp}.#{ext}")
513
+ end
514
+
515
+ def dispatch_export(path, format)
467
516
  if format == 'json'
468
517
  export_json(path)
469
518
  elsif format == 'html'
@@ -471,15 +520,8 @@ module Legion
471
520
  else
472
521
  export_markdown(path)
473
522
  end
474
- @message_stream.add_message(role: :system, content: "Exported to: #{path}")
475
- :handled
476
- rescue StandardError => e
477
- @message_stream.add_message(role: :system, content: "Export failed: #{e.message}")
478
- :handled
479
523
  end
480
524
 
481
- # rubocop:enable Metrics/AbcSize
482
-
483
525
  # rubocop:disable Metrics/AbcSize
484
526
  def handle_tools
485
527
  lex_gems = Gem::Specification.select { |s| s.name.start_with?('lex-') }
@@ -605,6 +647,7 @@ module Legion
605
647
  name = input.split(nil, 2)[1]
606
648
  if name
607
649
  if Theme.switch(name)
650
+ @status_bar.notify(message: "Theme: #{name}", level: :info, ttl: 2)
608
651
  @message_stream.add_message(role: :system, content: "Theme switched to: #{name}")
609
652
  else
610
653
  available = Theme.available_themes.join(', ')
@@ -853,6 +896,260 @@ module Legion
853
896
  text.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
854
897
  end
855
898
 
899
+ def handle_undo
900
+ msgs = @message_stream.messages
901
+ last_user_idx = msgs.rindex { |m| m[:role] == :user }
902
+ unless last_user_idx
903
+ @message_stream.add_message(role: :system, content: 'Nothing to undo.')
904
+ return :handled
905
+ end
906
+
907
+ msgs.slice!(last_user_idx..)
908
+ :handled
909
+ end
910
+
911
+ def handle_history
912
+ entries = @input_bar.history
913
+ if entries.empty?
914
+ @message_stream.add_message(role: :system, content: 'No input history.')
915
+ else
916
+ recent = entries.last(20)
917
+ lines = recent.each_with_index.map { |entry, i| " #{i + 1}. #{entry}" }
918
+ @message_stream.add_message(role: :system,
919
+ content: "Input history (last #{recent.size}):\n#{lines.join("\n")}")
920
+ end
921
+ :handled
922
+ end
923
+
924
+ def handle_pin(input)
925
+ idx_str = input.split(nil, 2)[1]
926
+ msg = if idx_str
927
+ @message_stream.messages[idx_str.to_i]
928
+ else
929
+ @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
930
+ end
931
+ unless msg
932
+ @message_stream.add_message(role: :system, content: 'No message to pin.')
933
+ return :handled
934
+ end
935
+
936
+ @pinned_messages << msg
937
+ preview = truncate_text(msg[:content].to_s, 60)
938
+ @message_stream.add_message(role: :system, content: "Pinned: #{preview}")
939
+ :handled
940
+ end
941
+
942
+ def handle_pins
943
+ if @pinned_messages.empty?
944
+ @message_stream.add_message(role: :system, content: 'No pinned messages.')
945
+ else
946
+ lines = @pinned_messages.each_with_index.map do |msg, i|
947
+ " #{i + 1}. [#{msg[:role]}] #{truncate_text(msg[:content].to_s, 70)}"
948
+ end
949
+ @message_stream.add_message(role: :system,
950
+ content: "Pinned messages (#{@pinned_messages.size}):\n#{lines.join("\n")}")
951
+ end
952
+ :handled
953
+ end
954
+
955
+ def handle_rename(input)
956
+ name = input.split(nil, 2)[1]
957
+ unless name
958
+ @message_stream.add_message(role: :system, content: 'Usage: /rename <new-name>')
959
+ return :handled
960
+ end
961
+
962
+ old_name = @session_name
963
+ @session_store.delete(old_name) if old_name != 'default'
964
+ @session_name = name
965
+ @status_bar.update(session: name)
966
+ @session_store.save(name, messages: @message_stream.messages)
967
+ @message_stream.add_message(role: :system, content: "Session renamed to '#{name}'.")
968
+ :handled
969
+ end
970
+
971
+ # rubocop:disable Metrics/AbcSize
972
+ def handle_context
973
+ cfg = safe_config
974
+ model_info = @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : (cfg[:provider] || 'none')
975
+ sys_prompt = if @llm_chat.respond_to?(:instructions) && @llm_chat.instructions
976
+ truncate_text(@llm_chat.instructions.to_s, 80)
977
+ else
978
+ 'default'
979
+ end
980
+ lines = [
981
+ 'Session Context:',
982
+ " Model/Provider : #{model_info}",
983
+ " Personality : #{@personality || 'default'}",
984
+ " Plan mode : #{@plan_mode ? 'on' : 'off'}",
985
+ " System prompt : #{sys_prompt}",
986
+ " Session : #{@session_name}",
987
+ " Messages : #{@message_stream.messages.size}",
988
+ " Pinned : #{@pinned_messages.size}",
989
+ " Tokens : #{@token_tracker.summary}"
990
+ ]
991
+ @message_stream.add_message(role: :system, content: lines.join("\n"))
992
+ :handled
993
+ end
994
+ # rubocop:enable Metrics/AbcSize
995
+
996
+ def handle_alias(input)
997
+ parts = input.split(nil, 3)
998
+ if parts.size < 2
999
+ if @aliases.empty?
1000
+ @message_stream.add_message(role: :system, content: 'No aliases defined.')
1001
+ else
1002
+ lines = @aliases.map { |k, v| " #{k} => #{v}" }
1003
+ @message_stream.add_message(role: :system, content: "Aliases:\n#{lines.join("\n")}")
1004
+ end
1005
+ return :handled
1006
+ end
1007
+
1008
+ shortname = parts[1]
1009
+ expansion = parts[2]
1010
+ unless expansion
1011
+ @message_stream.add_message(role: :system, content: 'Usage: /alias <shortname> <command and args>')
1012
+ return :handled
1013
+ end
1014
+
1015
+ alias_key = shortname.start_with?('/') ? shortname : "/#{shortname}"
1016
+ @aliases[alias_key] = expansion
1017
+ @message_stream.add_message(role: :system, content: "Alias created: #{alias_key} => #{expansion}")
1018
+ :handled
1019
+ end
1020
+
1021
+ def handle_snippet(input)
1022
+ parts = input.split(nil, 3)
1023
+ subcommand = parts[1]
1024
+ name = parts[2]
1025
+
1026
+ case subcommand
1027
+ when 'save'
1028
+ snippet_save(name)
1029
+ when 'load'
1030
+ snippet_load(name)
1031
+ when 'list'
1032
+ snippet_list
1033
+ when 'delete'
1034
+ snippet_delete(name)
1035
+ else
1036
+ @message_stream.add_message(
1037
+ role: :system,
1038
+ content: 'Usage: /snippet save|load|list|delete <name>'
1039
+ )
1040
+ end
1041
+ :handled
1042
+ end
1043
+
1044
+ def handle_debug
1045
+ @debug_mode = !@debug_mode
1046
+ if @debug_mode
1047
+ @status_bar.update(debug_mode: true)
1048
+ @message_stream.add_message(role: :system, content: 'Debug mode ON -- internal state shown below.')
1049
+ else
1050
+ @status_bar.update(debug_mode: false)
1051
+ @message_stream.add_message(role: :system, content: 'Debug mode OFF.')
1052
+ end
1053
+ :handled
1054
+ end
1055
+
1056
+ def debug_segment
1057
+ return nil unless @debug_mode
1058
+
1059
+ "[DEBUG] msgs:#{@message_stream.messages.size} " \
1060
+ "scroll:#{@message_stream.scroll_position&.dig(:current) || 0} " \
1061
+ "plan:#{@plan_mode} " \
1062
+ "personality:#{@personality || 'default'} " \
1063
+ "aliases:#{@aliases.size} " \
1064
+ "snippets:#{@snippets.size} " \
1065
+ "pinned:#{@pinned_messages.size}"
1066
+ end
1067
+
1068
+ def snippet_dir
1069
+ File.expand_path('~/.legionio/snippets')
1070
+ end
1071
+
1072
+ # rubocop:disable Metrics/AbcSize
1073
+ def snippet_save(name)
1074
+ unless name
1075
+ @message_stream.add_message(role: :system, content: 'Usage: /snippet save <name>')
1076
+ return
1077
+ end
1078
+
1079
+ last_assistant = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
1080
+ unless last_assistant
1081
+ @message_stream.add_message(role: :system, content: 'No assistant message to save as snippet.')
1082
+ return
1083
+ end
1084
+
1085
+ require 'fileutils'
1086
+ FileUtils.mkdir_p(snippet_dir)
1087
+ path = File.join(snippet_dir, "#{name}.txt")
1088
+ File.write(path, last_assistant[:content].to_s)
1089
+ @snippets[name] = last_assistant[:content].to_s
1090
+ @message_stream.add_message(role: :system, content: "Snippet '#{name}' saved.")
1091
+ end
1092
+ # rubocop:enable Metrics/AbcSize
1093
+
1094
+ def snippet_load(name)
1095
+ unless name
1096
+ @message_stream.add_message(role: :system, content: 'Usage: /snippet load <name>')
1097
+ return
1098
+ end
1099
+
1100
+ content = @snippets[name]
1101
+ if content.nil?
1102
+ path = File.join(snippet_dir, "#{name}.txt")
1103
+ content = File.read(path) if File.exist?(path)
1104
+ end
1105
+
1106
+ unless content
1107
+ @message_stream.add_message(role: :system, content: "Snippet '#{name}' not found.")
1108
+ return
1109
+ end
1110
+
1111
+ @snippets[name] = content
1112
+ @message_stream.add_message(role: :user, content: content)
1113
+ @message_stream.add_message(role: :system, content: "Snippet '#{name}' inserted.")
1114
+ end
1115
+
1116
+ # rubocop:disable Metrics/AbcSize
1117
+ def snippet_list
1118
+ disk_snippets = Dir.glob(File.join(snippet_dir, '*.txt')).map { |f| File.basename(f, '.txt') }
1119
+ all_names = (@snippets.keys + disk_snippets).uniq.sort
1120
+
1121
+ if all_names.empty?
1122
+ @message_stream.add_message(role: :system, content: 'No snippets saved.')
1123
+ return
1124
+ end
1125
+
1126
+ lines = all_names.map do |sname|
1127
+ content = @snippets[sname] || begin
1128
+ path = File.join(snippet_dir, "#{sname}.txt")
1129
+ File.exist?(path) ? File.read(path) : ''
1130
+ end
1131
+ " #{sname}: #{truncate_text(content.to_s, 60)}"
1132
+ end
1133
+ @message_stream.add_message(role: :system, content: "Snippets (#{all_names.size}):\n#{lines.join("\n")}")
1134
+ end
1135
+ # rubocop:enable Metrics/AbcSize
1136
+
1137
+ def snippet_delete(name)
1138
+ unless name
1139
+ @message_stream.add_message(role: :system, content: 'Usage: /snippet delete <name>')
1140
+ return
1141
+ end
1142
+
1143
+ @snippets.delete(name)
1144
+ path = File.join(snippet_dir, "#{name}.txt")
1145
+ if File.exist?(path)
1146
+ File.delete(path)
1147
+ @message_stream.add_message(role: :system, content: "Snippet '#{name}' deleted.")
1148
+ else
1149
+ @message_stream.add_message(role: :system, content: "Snippet '#{name}' not found.")
1150
+ end
1151
+ end
1152
+
856
1153
  def build_default_input_bar
857
1154
  cfg = safe_config
858
1155
  name = cfg[:name] || 'User'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.8'
5
+ VERSION = '0.4.10'
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.8
4
+ version: 0.4.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity