legion-tty 0.4.11 → 0.4.13

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.
@@ -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
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module TTY
5
+ module Screens
6
+ class Chat < Base
7
+ module SessionCommands
8
+ private
9
+
10
+ def handle_save(input)
11
+ name = input.split(nil, 2)[1] || @session_store.auto_session_name
12
+ @session_name = name
13
+ @session_store.save(name, messages: @message_stream.messages)
14
+ @status_bar.update(session: name)
15
+ @status_bar.notify(message: "Saved '#{name}'", level: :success, ttl: 3)
16
+ @message_stream.add_message(role: :system, content: "Session saved as '#{name}'.")
17
+ :handled
18
+ end
19
+
20
+ def handle_load(input)
21
+ name = input.split(nil, 2)[1]
22
+ unless name
23
+ @message_stream.add_message(role: :system, content: 'Usage: /load <session-name>')
24
+ return :handled
25
+ end
26
+ data = @session_store.load(name)
27
+ unless data
28
+ @message_stream.add_message(role: :system, content: "Session '#{name}' not found.")
29
+ return :handled
30
+ end
31
+ @message_stream.messages.replace(data[:messages])
32
+ @loaded_message_count = @message_stream.messages.size
33
+ @session_name = name
34
+ @status_bar.update(session: name)
35
+ @status_bar.notify(message: "Loaded '#{name}'", level: :info, ttl: 3)
36
+ @message_stream.add_message(role: :system,
37
+ content: "Session '#{name}' loaded (#{data[:messages].size} messages).")
38
+ :handled
39
+ end
40
+
41
+ def handle_sessions
42
+ sessions = @session_store.list
43
+ if sessions.empty?
44
+ @message_stream.add_message(role: :system, content: 'No saved sessions.')
45
+ else
46
+ lines = sessions.map { |s| " #{s[:name]} - #{s[:message_count]} messages (#{s[:saved_at]})" }
47
+ @message_stream.add_message(role: :system, content: "Saved sessions:\n#{lines.join("\n")}")
48
+ end
49
+ :handled
50
+ end
51
+
52
+ def handle_delete(input)
53
+ name = input.split(nil, 2)[1]
54
+ unless name
55
+ @message_stream.add_message(role: :system, content: 'Usage: /delete <session-name>')
56
+ return :handled
57
+ end
58
+ @session_store.delete(name)
59
+ @message_stream.add_message(role: :system, content: "Session '#{name}' deleted.")
60
+ :handled
61
+ end
62
+
63
+ def handle_rename(input)
64
+ name = input.split(nil, 2)[1]
65
+ unless name
66
+ @message_stream.add_message(role: :system, content: 'Usage: /rename <new-name>')
67
+ return :handled
68
+ end
69
+
70
+ old_name = @session_name
71
+ @session_store.delete(old_name) if old_name != 'default'
72
+ @session_name = name
73
+ @status_bar.update(session: name)
74
+ @session_store.save(name, messages: @message_stream.messages)
75
+ @message_stream.add_message(role: :system, content: "Session renamed to '#{name}'.")
76
+ :handled
77
+ end
78
+
79
+ def auto_save_session
80
+ return if @message_stream.messages.empty?
81
+
82
+ if @session_name == 'default'
83
+ @session_name = @session_store.auto_session_name(messages: @message_stream.messages)
84
+ end
85
+ @session_store.save(@session_name, messages: @message_stream.messages)
86
+ rescue StandardError
87
+ nil
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,238 @@
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 UiCommands
9
+ TIPS = [
10
+ 'Press Tab after / to auto-complete commands',
11
+ 'Use /alias to create shortcuts (e.g., /alias s /save)',
12
+ 'Press Ctrl+K to open the command palette',
13
+ 'Use /grep for regex search (e.g., /grep error|warning)',
14
+ 'Pin important messages with /pin, export with /bookmark',
15
+ 'Use /compact 3 to keep only the last 3 message pairs',
16
+ "Press 'o' in Extensions browser to open gem homepage",
17
+ '/export html creates a styled dark-theme HTML export',
18
+ 'Use /snippet save name to save assistant responses for reuse',
19
+ 'The dashboard updates every 5 seconds; press r to refresh',
20
+ '/context shows your full session state at a glance',
21
+ 'Use /personality technical for code-focused responses',
22
+ '/debug shows internal state counters in the status bar',
23
+ 'Navigate dashboard panels with j/k or number keys 1-5',
24
+ 'Use /diff to see new messages since a session was loaded'
25
+ ].freeze
26
+
27
+ HELP_TEXT = [
28
+ 'SESSION : /save /load /sessions /delete /rename',
29
+ 'CHAT : /clear /undo /compact /copy /search /grep /diff /stats',
30
+ 'LLM : /model /system /personality /cost',
31
+ 'NAV : /dashboard /extensions /config /palette /hotkeys',
32
+ 'DISPLAY : /theme /plan /debug /context /time /uptime',
33
+ 'TOOLS : /tools /export /bookmark /pin /pins /alias /snippet /history',
34
+ '',
35
+ 'Hotkeys: Ctrl+D=dashboard Ctrl+K=palette Ctrl+S=sessions Esc=back'
36
+ ].freeze
37
+
38
+ private
39
+
40
+ def handle_help
41
+ text = HELP_TEXT.join("\n")
42
+ if @app.respond_to?(:screen_manager) && @app.screen_manager
43
+ @app.screen_manager.show_overlay(text)
44
+ else
45
+ @message_stream.add_message(role: :system, content: text)
46
+ end
47
+ :handled
48
+ end
49
+
50
+ def handle_welcome
51
+ cfg = safe_config
52
+ @message_stream.add_message(
53
+ role: :system,
54
+ content: "Welcome#{", #{cfg[:name]}" if cfg[:name]}. Type /help for commands."
55
+ )
56
+ :handled
57
+ end
58
+
59
+ def handle_tips
60
+ tip = TIPS.sample
61
+ @message_stream.add_message(role: :system, content: "Tip: #{tip}")
62
+ :handled
63
+ end
64
+
65
+ def handle_clear
66
+ @message_stream.messages.clear
67
+ :handled
68
+ end
69
+
70
+ def handle_dashboard
71
+ if @app.respond_to?(:toggle_dashboard)
72
+ @app.toggle_dashboard
73
+ else
74
+ @message_stream.add_message(role: :system, content: 'Dashboard not available.')
75
+ end
76
+ :handled
77
+ end
78
+
79
+ def handle_hotkeys
80
+ if @app.respond_to?(:hotkeys)
81
+ bindings = @app.hotkeys.list
82
+ lines = bindings.map { |b| "#{b[:key].inspect} -> #{b[:description]}" }
83
+ text = lines.empty? ? 'No hotkeys registered.' : lines.join("\n")
84
+ @message_stream.add_message(role: :system, content: "Hotkeys:\n#{text}")
85
+ else
86
+ @message_stream.add_message(role: :system, content: 'Hotkeys not available.')
87
+ end
88
+ :handled
89
+ end
90
+
91
+ def handle_extensions_screen
92
+ require_relative '../screens/extensions'
93
+ screen = Screens::Extensions.new(@app, output: @output)
94
+ @app.screen_manager.push(screen)
95
+ :handled
96
+ rescue LoadError
97
+ @message_stream.add_message(role: :system, content: 'Extensions screen not available.')
98
+ :handled
99
+ end
100
+
101
+ def handle_config_screen
102
+ require_relative '../screens/config'
103
+ screen = Screens::Config.new(@app, output: @output)
104
+ @app.screen_manager.push(screen)
105
+ :handled
106
+ rescue LoadError
107
+ @message_stream.add_message(role: :system, content: 'Config screen not available.')
108
+ :handled
109
+ end
110
+
111
+ def handle_palette
112
+ require_relative '../components/command_palette'
113
+ palette = Components::CommandPalette.new(session_store: @session_store)
114
+ selection = palette.select_with_prompt(output: @output)
115
+ return :handled unless selection
116
+
117
+ if selection.start_with?('/')
118
+ handle_slash_command(selection)
119
+ else
120
+ dispatch_screen_by_name(selection)
121
+ end
122
+ :handled
123
+ end
124
+
125
+ # rubocop:disable Metrics/AbcSize
126
+ def handle_context
127
+ cfg = safe_config
128
+ model_info = @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : (cfg[:provider] || 'none')
129
+ sys_prompt = if @llm_chat.respond_to?(:instructions) && @llm_chat.instructions
130
+ truncate_text(@llm_chat.instructions.to_s, 80)
131
+ else
132
+ 'default'
133
+ end
134
+ lines = [
135
+ 'Session Context:',
136
+ " Model/Provider : #{model_info}",
137
+ " Personality : #{@personality || 'default'}",
138
+ " Plan mode : #{@plan_mode ? 'on' : 'off'}",
139
+ " System prompt : #{sys_prompt}",
140
+ " Session : #{@session_name}",
141
+ " Messages : #{@message_stream.messages.size}",
142
+ " Pinned : #{@pinned_messages.size}",
143
+ " Tokens : #{@token_tracker.summary}"
144
+ ]
145
+ @message_stream.add_message(role: :system, content: lines.join("\n"))
146
+ :handled
147
+ end
148
+ # rubocop:enable Metrics/AbcSize
149
+
150
+ def handle_stats
151
+ @message_stream.add_message(role: :system, content: build_stats_lines.join("\n"))
152
+ :handled
153
+ end
154
+
155
+ def handle_debug
156
+ @debug_mode = !@debug_mode
157
+ if @debug_mode
158
+ @status_bar.update(debug_mode: true)
159
+ @message_stream.add_message(role: :system, content: 'Debug mode ON -- internal state shown below.')
160
+ else
161
+ @status_bar.update(debug_mode: false)
162
+ @message_stream.add_message(role: :system, content: 'Debug mode OFF.')
163
+ end
164
+ :handled
165
+ end
166
+
167
+ def handle_history
168
+ entries = @input_bar.history
169
+ if entries.empty?
170
+ @message_stream.add_message(role: :system, content: 'No input history.')
171
+ else
172
+ recent = entries.last(20)
173
+ lines = recent.each_with_index.map { |entry, i| " #{i + 1}. #{entry}" }
174
+ @message_stream.add_message(role: :system,
175
+ content: "Input history (last #{recent.size}):\n#{lines.join("\n")}")
176
+ end
177
+ :handled
178
+ end
179
+
180
+ def handle_uptime
181
+ elapsed = Time.now - @session_start
182
+ hours = (elapsed / 3600).to_i
183
+ minutes = ((elapsed % 3600) / 60).to_i
184
+ seconds = (elapsed % 60).to_i
185
+ @message_stream.add_message(role: :system, content: "Session uptime: #{hours}h #{minutes}m #{seconds}s")
186
+ :handled
187
+ end
188
+
189
+ def handle_time
190
+ now = Time.now
191
+ tz = now.zone || 'local'
192
+ @message_stream.add_message(
193
+ role: :system,
194
+ content: "Current time: #{now.strftime('%Y-%m-%d %H:%M:%S')} #{tz}"
195
+ )
196
+ :handled
197
+ end
198
+
199
+ def dispatch_screen_by_name(name)
200
+ case name
201
+ when 'dashboard' then handle_dashboard
202
+ when 'extensions' then handle_extensions_screen
203
+ when 'config' then handle_config_screen
204
+ end
205
+ end
206
+
207
+ def build_stats_lines
208
+ msgs = @message_stream.messages
209
+ counts = count_by_role(msgs)
210
+ total_chars = msgs.sum { |m| m[:content].to_s.length }
211
+ lines = stats_header_lines(msgs, counts, total_chars)
212
+ lines << " Tool calls: #{counts[:tool]}" if counts[:tool].positive?
213
+ lines
214
+ end
215
+
216
+ def count_by_role(msgs)
217
+ %i[user assistant system tool].to_h { |role| [role, msgs.count { |m| m[:role] == role }] }
218
+ end
219
+
220
+ def stats_header_lines(msgs, counts, total_chars)
221
+ [
222
+ "Messages: #{msgs.size} total",
223
+ " User: #{counts[:user]}, Assistant: #{counts[:assistant]}, System: #{counts[:system]}",
224
+ "Characters: #{format_stat_number(total_chars)}",
225
+ "Session: #{@session_name}",
226
+ "Tokens: #{@token_tracker.summary}"
227
+ ]
228
+ end
229
+
230
+ def format_stat_number(num)
231
+ num.to_s.chars.reverse.each_slice(3).map(&:join).join(',').reverse
232
+ end
233
+ end
234
+ # rubocop:enable Metrics/ModuleLength
235
+ end
236
+ end
237
+ end
238
+ end