legion-tty 0.4.11 → 0.4.12

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: 3e2b8a679110725634b3db870bb623fc8f5cef89706890f9c57c579490c85114
4
- data.tar.gz: 6487e27647ed48406731a9c0c1bf1664b64831b60139dd698ae794d2dc8d3871
3
+ metadata.gz: 9506a28fb68d7623dd1e70e2b69b80e80b2a96486c068dbc2d13a7e2bd9a80ab
4
+ data.tar.gz: 7ce9e32334ba94891569b073e04246240d8b1637edb930f0c8c86ed80841a155
5
5
  SHA512:
6
- metadata.gz: 9c89fb3dbcb807c628889b2b60566b3b23cad1356c26de42cd1d02d75a2977e7856528b0193e8bb9dd630ee4cf52b2b55e06e009c8fc3e34550a1712d6768b68
7
- data.tar.gz: ae426495d1eb8708723a5c2804a4a77e4a557480324c23b370b0ebe324ea1a7ece957665b133f7810952f03f586cb0e87dcef3e1c78e9fcde81fe13e0e6e4760
6
+ metadata.gz: b7cf759adc8fcaad726906fd6e95d12a00d5e09bbf3724685a1fae91bdd31d9df31036bc4d826cc7b756f2cb97ae2d560290dbb6c1f0473b8334da48508d5a39
7
+ data.tar.gz: 7fcce87bc7f909f1e5b5c6d468871860a0a2be6f621a8b446d7948cf2761bf354035911d946c134b74d04b226e19f2b174c010d1fb9e7a39533162f826883865
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.12] - 2026-03-19
4
+
5
+ ### Added
6
+ - `/grep <pattern>` command: regex search across message history (case-insensitive, with RegexpError handling)
7
+ - `/time` command: display current date, time, and timezone
8
+
9
+ ### Changed
10
+ - Refactored Chat screen into 6 concern modules (chat.rb 1220 -> 466 lines):
11
+ - `chat/session_commands.rb` — save/load/sessions/delete/rename
12
+ - `chat/export_commands.rb` — export/bookmark/html/json/markdown
13
+ - `chat/message_commands.rb` — compact/copy/diff/search/grep/undo/pin/pins
14
+ - `chat/ui_commands.rb` — help/clear/dashboard/hotkeys/palette/context/stats/debug/history/uptime/time
15
+ - `chat/model_commands.rb` — model/system/personality switching
16
+ - `chat/custom_commands.rb` — alias/snippet management
17
+
3
18
  ## [0.4.11] - 2026-03-19
4
19
 
5
20
  ### Added
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module TTY
5
+ module Screens
6
+ class Chat < Base
7
+ # rubocop:disable Metrics/ModuleLength
8
+ module CustomCommands
9
+ private
10
+
11
+ def handle_alias(input)
12
+ parts = input.split(nil, 3)
13
+ if parts.size < 2
14
+ if @aliases.empty?
15
+ @message_stream.add_message(role: :system, content: 'No aliases defined.')
16
+ else
17
+ lines = @aliases.map { |k, v| " #{k} => #{v}" }
18
+ @message_stream.add_message(role: :system, content: "Aliases:\n#{lines.join("\n")}")
19
+ end
20
+ return :handled
21
+ end
22
+
23
+ shortname = parts[1]
24
+ expansion = parts[2]
25
+ unless expansion
26
+ @message_stream.add_message(role: :system, content: 'Usage: /alias <shortname> <command and args>')
27
+ return :handled
28
+ end
29
+
30
+ alias_key = shortname.start_with?('/') ? shortname : "/#{shortname}"
31
+ @aliases[alias_key] = expansion
32
+ @message_stream.add_message(role: :system, content: "Alias created: #{alias_key} => #{expansion}")
33
+ :handled
34
+ end
35
+
36
+ def handle_snippet(input)
37
+ parts = input.split(nil, 3)
38
+ subcommand = parts[1]
39
+ name = parts[2]
40
+
41
+ case subcommand
42
+ when 'save'
43
+ snippet_save(name)
44
+ when 'load'
45
+ snippet_load(name)
46
+ when 'list'
47
+ snippet_list
48
+ when 'delete'
49
+ snippet_delete(name)
50
+ else
51
+ @message_stream.add_message(
52
+ role: :system,
53
+ content: 'Usage: /snippet save|load|list|delete <name>'
54
+ )
55
+ end
56
+ :handled
57
+ end
58
+
59
+ def snippet_dir
60
+ File.expand_path('~/.legionio/snippets')
61
+ end
62
+
63
+ # rubocop:disable Metrics/AbcSize
64
+ def snippet_save(name)
65
+ unless name
66
+ @message_stream.add_message(role: :system, content: 'Usage: /snippet save <name>')
67
+ return
68
+ end
69
+
70
+ last_assistant = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
71
+ unless last_assistant
72
+ @message_stream.add_message(role: :system, content: 'No assistant message to save as snippet.')
73
+ return
74
+ end
75
+
76
+ require 'fileutils'
77
+ FileUtils.mkdir_p(snippet_dir)
78
+ path = File.join(snippet_dir, "#{name}.txt")
79
+ File.write(path, last_assistant[:content].to_s)
80
+ @snippets[name] = last_assistant[:content].to_s
81
+ @message_stream.add_message(role: :system, content: "Snippet '#{name}' saved.")
82
+ end
83
+ # rubocop:enable Metrics/AbcSize
84
+
85
+ def snippet_load(name)
86
+ unless name
87
+ @message_stream.add_message(role: :system, content: 'Usage: /snippet load <name>')
88
+ return
89
+ end
90
+
91
+ content = @snippets[name]
92
+ if content.nil?
93
+ path = File.join(snippet_dir, "#{name}.txt")
94
+ content = File.read(path) if File.exist?(path)
95
+ end
96
+
97
+ unless content
98
+ @message_stream.add_message(role: :system, content: "Snippet '#{name}' not found.")
99
+ return
100
+ end
101
+
102
+ @snippets[name] = content
103
+ @message_stream.add_message(role: :user, content: content)
104
+ @message_stream.add_message(role: :system, content: "Snippet '#{name}' inserted.")
105
+ end
106
+
107
+ # rubocop:disable Metrics/AbcSize
108
+ def snippet_list
109
+ disk_snippets = Dir.glob(File.join(snippet_dir, '*.txt')).map { |f| File.basename(f, '.txt') }
110
+ all_names = (@snippets.keys + disk_snippets).uniq.sort
111
+
112
+ if all_names.empty?
113
+ @message_stream.add_message(role: :system, content: 'No snippets saved.')
114
+ return
115
+ end
116
+
117
+ lines = all_names.map do |sname|
118
+ content = @snippets[sname] || begin
119
+ path = File.join(snippet_dir, "#{sname}.txt")
120
+ File.exist?(path) ? File.read(path) : ''
121
+ end
122
+ " #{sname}: #{truncate_text(content.to_s, 60)}"
123
+ end
124
+ @message_stream.add_message(role: :system, content: "Snippets (#{all_names.size}):\n#{lines.join("\n")}")
125
+ end
126
+ # rubocop:enable Metrics/AbcSize
127
+
128
+ def snippet_delete(name)
129
+ unless name
130
+ @message_stream.add_message(role: :system, content: 'Usage: /snippet delete <name>')
131
+ return
132
+ end
133
+
134
+ @snippets.delete(name)
135
+ path = File.join(snippet_dir, "#{name}.txt")
136
+ if File.exist?(path)
137
+ File.delete(path)
138
+ @message_stream.add_message(role: :system, content: "Snippet '#{name}' deleted.")
139
+ else
140
+ @message_stream.add_message(role: :system, content: "Snippet '#{name}' not found.")
141
+ end
142
+ end
143
+ end
144
+ # rubocop:enable Metrics/ModuleLength
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module TTY
5
+ module Screens
6
+ class Chat < Base
7
+ module ExportCommands
8
+ private
9
+
10
+ def handle_export(input)
11
+ require 'fileutils'
12
+ path = build_export_path(input)
13
+ dispatch_export(path, input.split[1]&.downcase)
14
+ @status_bar.notify(message: 'Exported', level: :success, ttl: 3)
15
+ @message_stream.add_message(role: :system, content: "Exported to: #{path}")
16
+ :handled
17
+ rescue StandardError => e
18
+ @message_stream.add_message(role: :system, content: "Export failed: #{e.message}")
19
+ :handled
20
+ end
21
+
22
+ def build_export_path(input)
23
+ format = input.split[1]&.downcase
24
+ format = 'md' unless %w[json md html].include?(format)
25
+ exports_dir = File.expand_path('~/.legionio/exports')
26
+ FileUtils.mkdir_p(exports_dir)
27
+ timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
28
+ ext = { 'json' => 'json', 'md' => 'md', 'html' => 'html' }[format]
29
+ File.join(exports_dir, "chat-#{timestamp}.#{ext}")
30
+ end
31
+
32
+ def dispatch_export(path, format)
33
+ if format == 'json'
34
+ export_json(path)
35
+ elsif format == 'html'
36
+ export_html(path)
37
+ else
38
+ export_markdown(path)
39
+ end
40
+ end
41
+
42
+ def export_markdown(path)
43
+ lines = ["# Chat Export\n", "_Exported: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}_\n\n---\n"]
44
+ @message_stream.messages.each do |msg|
45
+ role_label = msg[:role].to_s.capitalize
46
+ lines << "\n**#{role_label}**\n\n#{msg[:content]}\n"
47
+ end
48
+ File.write(path, lines.join)
49
+ end
50
+
51
+ def export_json(path)
52
+ require 'json'
53
+ data = {
54
+ exported_at: Time.now.iso8601,
55
+ token_summary: @token_tracker.summary,
56
+ messages: @message_stream.messages.map { |m| { role: m[:role].to_s, content: m[:content] } }
57
+ }
58
+ File.write(path, ::JSON.pretty_generate(data))
59
+ end
60
+
61
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
62
+ def export_html(path)
63
+ lines = [
64
+ '<!DOCTYPE html><html><head>',
65
+ '<meta charset="utf-8">',
66
+ '<title>Chat Export</title>',
67
+ '<style>',
68
+ 'body { font-family: system-ui; max-width: 800px; margin: 0 auto; ' \
69
+ 'padding: 20px; background: #1e1b2e; color: #d0cce6; }',
70
+ '.msg { margin: 12px 0; padding: 8px 12px; border-radius: 8px; }',
71
+ '.user { background: #2a2640; }',
72
+ '.assistant { background: #1a1730; }',
73
+ '.system { background: #25223a; color: #8b85a8; font-style: italic; }',
74
+ '.role { font-weight: bold; color: #9d91e6; font-size: 0.85em; }',
75
+ '</style></head><body>',
76
+ '<h1>Chat Export</h1>',
77
+ "<p>Exported: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}</p>"
78
+ ]
79
+ @message_stream.messages.each do |msg|
80
+ role = msg[:role].to_s
81
+ content = escape_html(msg[:content].to_s).gsub("\n", '<br>')
82
+ lines << "<div class='msg #{role}'>"
83
+ lines << "<span class='role'>#{role.capitalize}</span>"
84
+ lines << "<p>#{content}</p>"
85
+ lines << '</div>'
86
+ end
87
+ lines << '</body></html>'
88
+ File.write(path, lines.join("\n"))
89
+ end
90
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
91
+
92
+ def escape_html(text)
93
+ text.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
94
+ end
95
+
96
+ # rubocop:disable Metrics/AbcSize
97
+ def handle_bookmark
98
+ require 'fileutils'
99
+ if @pinned_messages.empty?
100
+ @message_stream.add_message(role: :system, content: 'No pinned messages to export.')
101
+ return :handled
102
+ end
103
+
104
+ exports_dir = File.expand_path('~/.legionio/exports')
105
+ FileUtils.mkdir_p(exports_dir)
106
+ timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
107
+ path = File.join(exports_dir, "bookmarks-#{timestamp}.md")
108
+ lines = ["# Pinned Messages\n", "_Exported: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}_\n\n---\n"]
109
+ @pinned_messages.each_with_index do |msg, i|
110
+ role_label = msg[:role].to_s.capitalize
111
+ lines << "\n## Bookmark #{i + 1} (#{role_label})\n\n#{msg[:content]}\n"
112
+ end
113
+ File.write(path, lines.join)
114
+ @message_stream.add_message(role: :system, content: "Bookmarks exported to: #{path}")
115
+ :handled
116
+ rescue StandardError => e
117
+ @message_stream.add_message(role: :system, content: "Bookmark export failed: #{e.message}")
118
+ :handled
119
+ end
120
+ # rubocop:enable Metrics/AbcSize
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module TTY
5
+ module Screens
6
+ class Chat < Base
7
+ # rubocop:disable Metrics/ModuleLength
8
+ module MessageCommands
9
+ private
10
+
11
+ # rubocop:disable Metrics/AbcSize
12
+ def handle_compact(input)
13
+ keep = (input.split(nil, 2)[1] || '5').to_i.clamp(1, 50)
14
+ msgs = @message_stream.messages
15
+ if msgs.size <= keep * 2
16
+ @message_stream.add_message(role: :system, content: 'Conversation is already compact.')
17
+ return :handled
18
+ end
19
+
20
+ system_msgs = msgs.select { |m| m[:role] == :system }
21
+ recent = msgs.reject { |m| m[:role] == :system }.last(keep * 2)
22
+ removed_count = msgs.size - system_msgs.size - recent.size
23
+ @message_stream.messages.replace(system_msgs + recent)
24
+ @message_stream.add_message(
25
+ role: :system,
26
+ content: "Compacted: removed #{removed_count} older messages, kept #{recent.size} recent."
27
+ )
28
+ :handled
29
+ end
30
+ # rubocop:enable Metrics/AbcSize
31
+
32
+ def handle_copy(_input)
33
+ last_assistant = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
34
+ unless last_assistant
35
+ @message_stream.add_message(role: :system, content: 'No assistant message to copy.')
36
+ return :handled
37
+ end
38
+
39
+ content = last_assistant[:content].to_s
40
+ copy_to_clipboard(content)
41
+ @message_stream.add_message(
42
+ role: :system,
43
+ content: "Copied #{content.length} characters to clipboard."
44
+ )
45
+ :handled
46
+ end
47
+
48
+ def handle_diff(_input)
49
+ if @loaded_message_count.nil?
50
+ @message_stream.add_message(role: :system, content: 'No session was loaded. Nothing to diff against.')
51
+ return :handled
52
+ end
53
+
54
+ new_count = @message_stream.messages.size - @loaded_message_count
55
+ if new_count <= 0
56
+ @message_stream.add_message(role: :system, content: 'No new messages since session was loaded.')
57
+ else
58
+ new_msgs = @message_stream.messages.last(new_count)
59
+ lines = new_msgs.map { |m| " + [#{m[:role]}] #{truncate_text(m[:content].to_s, 60)}" }
60
+ @message_stream.add_message(
61
+ role: :system,
62
+ content: "#{new_count} new message(s) since load:\n#{lines.join("\n")}"
63
+ )
64
+ end
65
+ :handled
66
+ end
67
+
68
+ def handle_search(input)
69
+ query = input.split(nil, 2)[1]
70
+ unless query
71
+ @message_stream.add_message(role: :system, content: 'Usage: /search <text>')
72
+ return :handled
73
+ end
74
+
75
+ results = search_messages(query)
76
+ if results.empty?
77
+ @message_stream.add_message(role: :system, content: "No messages matching '#{query}'.")
78
+ else
79
+ lines = results.map { |r| " [#{r[:role]}] #{truncate_text(r[:content], 80)}" }
80
+ @message_stream.add_message(
81
+ role: :system,
82
+ content: "Found #{results.size} message(s) matching '#{query}':\n#{lines.join("\n")}"
83
+ )
84
+ end
85
+ :handled
86
+ end
87
+
88
+ def handle_grep(input)
89
+ pattern_str = input.split(nil, 2)[1]
90
+ unless pattern_str
91
+ @message_stream.add_message(role: :system, content: 'Usage: /grep <regex>')
92
+ return :handled
93
+ end
94
+
95
+ results = grep_messages(pattern_str)
96
+ display_grep_results(results, pattern_str)
97
+ :handled
98
+ rescue RegexpError => e
99
+ @message_stream.add_message(role: :system, content: "Invalid regex: #{e.message}")
100
+ :handled
101
+ end
102
+
103
+ def grep_messages(pattern_str)
104
+ regex = Regexp.new(pattern_str, Regexp::IGNORECASE)
105
+ @message_stream.messages.select do |msg|
106
+ msg[:content].is_a?(::String) && regex.match?(msg[:content])
107
+ end
108
+ end
109
+
110
+ def display_grep_results(results, pattern_str)
111
+ if results.empty?
112
+ @message_stream.add_message(role: :system, content: "No messages matching /#{pattern_str}/.")
113
+ else
114
+ lines = results.map { |r| " [#{r[:role]}] #{truncate_text(r[:content], 80)}" }
115
+ @message_stream.add_message(
116
+ role: :system,
117
+ content: "Found #{results.size} message(s) matching /#{pattern_str}/:\n#{lines.join("\n")}"
118
+ )
119
+ end
120
+ end
121
+
122
+ def handle_undo
123
+ msgs = @message_stream.messages
124
+ last_user_idx = msgs.rindex { |m| m[:role] == :user }
125
+ unless last_user_idx
126
+ @message_stream.add_message(role: :system, content: 'Nothing to undo.')
127
+ return :handled
128
+ end
129
+
130
+ msgs.slice!(last_user_idx..)
131
+ :handled
132
+ end
133
+
134
+ def handle_pin(input)
135
+ idx_str = input.split(nil, 2)[1]
136
+ msg = if idx_str
137
+ @message_stream.messages[idx_str.to_i]
138
+ else
139
+ @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
140
+ end
141
+ unless msg
142
+ @message_stream.add_message(role: :system, content: 'No message to pin.')
143
+ return :handled
144
+ end
145
+
146
+ @pinned_messages << msg
147
+ preview = truncate_text(msg[:content].to_s, 60)
148
+ @message_stream.add_message(role: :system, content: "Pinned: #{preview}")
149
+ :handled
150
+ end
151
+
152
+ def handle_pins
153
+ if @pinned_messages.empty?
154
+ @message_stream.add_message(role: :system, content: 'No pinned messages.')
155
+ else
156
+ lines = @pinned_messages.each_with_index.map do |msg, i|
157
+ " #{i + 1}. [#{msg[:role]}] #{truncate_text(msg[:content].to_s, 70)}"
158
+ end
159
+ @message_stream.add_message(role: :system,
160
+ content: "Pinned messages (#{@pinned_messages.size}):\n#{lines.join("\n")}")
161
+ end
162
+ :handled
163
+ end
164
+
165
+ def search_messages(query)
166
+ pattern = query.downcase
167
+ @message_stream.messages.select do |msg|
168
+ msg[:content].is_a?(::String) && msg[:content].downcase.include?(pattern)
169
+ end
170
+ end
171
+
172
+ def truncate_text(text, max_length)
173
+ return text if text.length <= max_length
174
+
175
+ "#{text[0...max_length]}..."
176
+ end
177
+
178
+ def copy_to_clipboard(text)
179
+ IO.popen('pbcopy', 'w') { |io| io.write(text) }
180
+ rescue Errno::ENOENT
181
+ begin
182
+ IO.popen('xclip -selection clipboard', 'w') { |io| io.write(text) }
183
+ rescue Errno::ENOENT
184
+ nil
185
+ end
186
+ end
187
+ end
188
+ # rubocop:enable Metrics/ModuleLength
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module TTY
5
+ module Screens
6
+ class Chat < Base
7
+ module ModelCommands
8
+ private
9
+
10
+ def handle_model(input)
11
+ name = input.split(nil, 2)[1]
12
+ if name
13
+ switch_model(name)
14
+ else
15
+ show_current_model
16
+ end
17
+ :handled
18
+ end
19
+
20
+ def switch_model(name)
21
+ unless @llm_chat
22
+ @message_stream.add_message(role: :system, content: 'No active LLM session.')
23
+ return
24
+ end
25
+
26
+ apply_model_switch(name)
27
+ rescue StandardError => e
28
+ @message_stream.add_message(role: :system, content: "Failed to switch model: #{e.message}")
29
+ end
30
+
31
+ def apply_model_switch(name)
32
+ new_chat = try_provider_switch(name)
33
+ if new_chat
34
+ @llm_chat = new_chat
35
+ @status_bar.update(model: name)
36
+ @token_tracker.update_model(name)
37
+ @message_stream.add_message(role: :system, content: "Switched to provider: #{name}")
38
+ elsif @llm_chat.respond_to?(:with_model)
39
+ @llm_chat.with_model(name)
40
+ @status_bar.update(model: name)
41
+ @token_tracker.update_model(name)
42
+ @message_stream.add_message(role: :system, content: "Model switched to: #{name}")
43
+ else
44
+ @status_bar.update(model: name)
45
+ @message_stream.add_message(role: :system, content: "Model set to: #{name}")
46
+ end
47
+ end
48
+
49
+ def try_provider_switch(name)
50
+ return nil unless defined?(Legion::LLM)
51
+
52
+ providers = Legion::LLM.settings[:providers]
53
+ return nil unless providers.is_a?(Hash) && providers.key?(name.to_sym)
54
+
55
+ Legion::LLM.chat(provider: name)
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ def open_model_picker
61
+ require_relative '../components/model_picker'
62
+ picker = Components::ModelPicker.new(
63
+ current_provider: safe_config[:provider],
64
+ current_model: @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : nil
65
+ )
66
+ selection = picker.select_with_prompt(output: @output)
67
+ return unless selection
68
+
69
+ switch_model(selection[:provider])
70
+ end
71
+
72
+ def show_current_model
73
+ model = @llm_chat.respond_to?(:model) ? @llm_chat.model : nil
74
+ provider = safe_config[:provider] || 'unknown'
75
+ info = model ? "#{model} (#{provider})" : provider
76
+ @message_stream.add_message(role: :system, content: "Current model: #{info}")
77
+ end
78
+
79
+ def handle_system(input)
80
+ text = input.split(nil, 2)[1]
81
+ if text
82
+ if @llm_chat.respond_to?(:with_instructions)
83
+ @llm_chat.with_instructions(text)
84
+ @message_stream.add_message(role: :system, content: 'System prompt updated.')
85
+ else
86
+ @message_stream.add_message(role: :system, content: 'No active LLM session.')
87
+ end
88
+ else
89
+ @message_stream.add_message(role: :system, content: 'Usage: /system <prompt text>')
90
+ end
91
+ :handled
92
+ end
93
+
94
+ def handle_personality(input)
95
+ name = input.split(nil, 2)[1]
96
+ if name && PERSONALITIES.key?(name)
97
+ apply_personality(name)
98
+ elsif name
99
+ available = PERSONALITIES.keys.join(', ')
100
+ @message_stream.add_message(role: :system,
101
+ content: "Unknown personality '#{name}'. Available: #{available}")
102
+ else
103
+ current = @personality || 'default'
104
+ available = PERSONALITIES.keys.join(', ')
105
+ @message_stream.add_message(role: :system, content: "Current: #{current}\nAvailable: #{available}")
106
+ end
107
+ :handled
108
+ end
109
+
110
+ def apply_personality(name)
111
+ @personality = name
112
+ if @llm_chat.respond_to?(:with_instructions)
113
+ @llm_chat.with_instructions(PERSONALITIES[name])
114
+ @message_stream.add_message(role: :system, content: "Personality switched to: #{name}")
115
+ else
116
+ @message_stream.add_message(role: :system, content: "Personality set to: #{name} (no active LLM)")
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end