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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +47 -21
- data/lib/legion/tty/components/status_bar.rb +9 -1
- data/lib/legion/tty/screens/chat/custom_commands.rb +148 -0
- data/lib/legion/tty/screens/chat/export_commands.rb +125 -0
- data/lib/legion/tty/screens/chat/message_commands.rb +192 -0
- data/lib/legion/tty/screens/chat/model_commands.rb +123 -0
- data/lib/legion/tty/screens/chat/session_commands.rb +93 -0
- data/lib/legion/tty/screens/chat/ui_commands.rb +238 -0
- data/lib/legion/tty/screens/chat.rb +49 -799
- data/lib/legion/tty/version.rb +1 -1
- metadata +7 -1
|
@@ -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
|