legion-tty 0.4.13 → 0.4.15
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 +16 -0
- data/lib/legion/tty/components/message_stream.rb +20 -3
- data/lib/legion/tty/screens/chat/custom_commands.rb +99 -0
- data/lib/legion/tty/screens/chat/message_commands.rb +92 -0
- data/lib/legion/tty/screens/chat/session_commands.rb +71 -0
- data/lib/legion/tty/screens/chat/ui_commands.rb +47 -0
- data/lib/legion/tty/screens/chat.rb +42 -5
- data/lib/legion/tty/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 22a1a4b5a0dcae6da430188229f80fc7dfbbdbbc75c7b58516d93d7b35ee60cc
|
|
4
|
+
data.tar.gz: 309f256644b595a3f1aa15501482d40b5c27b26aa18707fd237207c479f14946
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2fe62fbe7805f0874f1988c7b1d226c00304bcfd86c84011b6e5a95707e684170d91966d1947360ed3ce021328ed3e57f445bb2b85cc5463891225b58f796a0
|
|
7
|
+
data.tar.gz: a6cf5433e3a80fa06f72003ae079d8cdf3e1b30cc1adaa64555c78becb3fc1a530889c9aacf3c031e988ff55c28ef8463e37e89196d713d6b9c87e238698871c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.15] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `/autosave [N|off]` command: toggle periodic auto-save with configurable interval (default 60s)
|
|
7
|
+
- `/react <emoji>` command: add emoji reactions to messages (displayed in render)
|
|
8
|
+
- `/macro record|stop|play|list|delete` command: record and replay slash command sequences
|
|
9
|
+
- `/tag` and `/tags` commands: tag messages with labels, filter by tag, show tag statistics
|
|
10
|
+
|
|
11
|
+
## [0.4.14] - 2026-03-19
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- LLM response timing: tracks elapsed time per response, shows notification, includes avg in `/stats`
|
|
15
|
+
- `/wc` command: word count statistics per role (user/assistant/system) with averages
|
|
16
|
+
- `/import <path>` command: import session from any JSON file path with validation
|
|
17
|
+
- `/mute` command: toggle system message display in chat (messages still tracked, just hidden)
|
|
18
|
+
|
|
3
19
|
## [0.4.13] - 2026-03-19
|
|
4
20
|
|
|
5
21
|
### Added
|
|
@@ -5,12 +5,15 @@ require_relative '../theme'
|
|
|
5
5
|
module Legion
|
|
6
6
|
module TTY
|
|
7
7
|
module Components
|
|
8
|
+
# rubocop:disable Metrics/ClassLength
|
|
8
9
|
class MessageStream
|
|
9
10
|
attr_reader :messages, :scroll_offset
|
|
11
|
+
attr_accessor :mute_system
|
|
10
12
|
|
|
11
13
|
def initialize
|
|
12
14
|
@messages = []
|
|
13
15
|
@scroll_offset = 0
|
|
16
|
+
@mute_system = false
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def add_message(role:, content:)
|
|
@@ -69,7 +72,11 @@ module Legion
|
|
|
69
72
|
private
|
|
70
73
|
|
|
71
74
|
def build_all_lines(width)
|
|
72
|
-
@messages.flat_map
|
|
75
|
+
@messages.flat_map do |msg|
|
|
76
|
+
next [] if @mute_system && msg[:role] == :system
|
|
77
|
+
|
|
78
|
+
render_message(msg, width)
|
|
79
|
+
end
|
|
73
80
|
end
|
|
74
81
|
|
|
75
82
|
def render_message(msg, width)
|
|
@@ -89,7 +96,9 @@ module Legion
|
|
|
89
96
|
def user_lines(msg, _width)
|
|
90
97
|
ts = format_timestamp(msg[:timestamp])
|
|
91
98
|
header = "#{Theme.c(:accent, 'You')} #{Theme.c(:muted, ts)}"
|
|
92
|
-
['', "#{header}: #{msg[:content]}"]
|
|
99
|
+
lines = ['', "#{header}: #{msg[:content]}"]
|
|
100
|
+
lines << reaction_line(msg) if msg[:reactions]&.any?
|
|
101
|
+
lines
|
|
93
102
|
end
|
|
94
103
|
|
|
95
104
|
def format_timestamp(time)
|
|
@@ -100,7 +109,14 @@ module Legion
|
|
|
100
109
|
|
|
101
110
|
def assistant_lines(msg, width)
|
|
102
111
|
rendered = render_markdown(msg[:content], width)
|
|
103
|
-
['', *rendered.split("\n")]
|
|
112
|
+
lines = ['', *rendered.split("\n")]
|
|
113
|
+
lines << reaction_line(msg) if msg[:reactions]&.any?
|
|
114
|
+
lines
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def reaction_line(msg)
|
|
118
|
+
reactions = msg[:reactions].map { |r| "[#{r}]" }.join(' ')
|
|
119
|
+
" #{Theme.c(:muted, reactions)}"
|
|
104
120
|
end
|
|
105
121
|
|
|
106
122
|
def render_markdown(text, width)
|
|
@@ -131,6 +147,7 @@ module Legion
|
|
|
131
147
|
panel.instance_variable_set(:@error, error) if error
|
|
132
148
|
end
|
|
133
149
|
end
|
|
150
|
+
# rubocop:enable Metrics/ClassLength
|
|
134
151
|
end
|
|
135
152
|
end
|
|
136
153
|
end
|
|
@@ -125,6 +125,105 @@ module Legion
|
|
|
125
125
|
end
|
|
126
126
|
# rubocop:enable Metrics/AbcSize
|
|
127
127
|
|
|
128
|
+
# rubocop:disable Metrics/MethodLength
|
|
129
|
+
def handle_macro(input)
|
|
130
|
+
parts = input.split(nil, 3)
|
|
131
|
+
subcommand = parts[1]
|
|
132
|
+
name = parts[2]
|
|
133
|
+
|
|
134
|
+
case subcommand
|
|
135
|
+
when 'record'
|
|
136
|
+
macro_record(name)
|
|
137
|
+
when 'stop'
|
|
138
|
+
macro_stop
|
|
139
|
+
when 'play'
|
|
140
|
+
macro_play(name)
|
|
141
|
+
when 'list'
|
|
142
|
+
macro_list
|
|
143
|
+
when 'delete'
|
|
144
|
+
macro_delete(name)
|
|
145
|
+
else
|
|
146
|
+
@message_stream.add_message(
|
|
147
|
+
role: :system,
|
|
148
|
+
content: 'Usage: /macro record|stop|play|list|delete <name>'
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
:handled
|
|
152
|
+
end
|
|
153
|
+
# rubocop:enable Metrics/MethodLength
|
|
154
|
+
|
|
155
|
+
def macro_record(name)
|
|
156
|
+
unless name
|
|
157
|
+
@message_stream.add_message(role: :system, content: 'Usage: /macro record <name>')
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
@recording_macro = name
|
|
162
|
+
@macro_buffer = []
|
|
163
|
+
@message_stream.add_message(role: :system,
|
|
164
|
+
content: "Recording macro '#{name}'... Use /macro stop to finish.")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def macro_stop
|
|
168
|
+
unless @recording_macro
|
|
169
|
+
@message_stream.add_message(role: :system, content: 'No macro recording in progress.')
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
name = @recording_macro
|
|
174
|
+
@macros[name] = @macro_buffer.dup
|
|
175
|
+
@recording_macro = nil
|
|
176
|
+
@macro_buffer = []
|
|
177
|
+
@message_stream.add_message(role: :system,
|
|
178
|
+
content: "Macro '#{name}' saved (#{@macros[name].size} commands).")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def macro_play(name)
|
|
182
|
+
unless name
|
|
183
|
+
@message_stream.add_message(role: :system, content: 'Usage: /macro play <name>')
|
|
184
|
+
return
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
commands = @macros[name]
|
|
188
|
+
unless commands
|
|
189
|
+
@message_stream.add_message(role: :system, content: "Macro '#{name}' not found.")
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
@message_stream.add_message(role: :system,
|
|
194
|
+
content: "Playing macro '#{name}' (#{commands.size} commands)...")
|
|
195
|
+
commands.each { |cmd| handle_slash_command(cmd) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def macro_list
|
|
199
|
+
if @macros.empty?
|
|
200
|
+
@message_stream.add_message(role: :system, content: 'No macros defined.')
|
|
201
|
+
return
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
lines = @macros.map do |n, cmds|
|
|
205
|
+
preview = cmds.first(3).join(', ')
|
|
206
|
+
preview += ', ...' if cmds.size > 3
|
|
207
|
+
" #{n} (#{cmds.size}): #{preview}"
|
|
208
|
+
end
|
|
209
|
+
status = @recording_macro ? " [recording: #{@recording_macro}]" : ''
|
|
210
|
+
@message_stream.add_message(role: :system,
|
|
211
|
+
content: "Macros (#{@macros.size})#{status}:\n#{lines.join("\n")}")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def macro_delete(name)
|
|
215
|
+
unless name
|
|
216
|
+
@message_stream.add_message(role: :system, content: 'Usage: /macro delete <name>')
|
|
217
|
+
return
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
if @macros.delete(name)
|
|
221
|
+
@message_stream.add_message(role: :system, content: "Macro '#{name}' deleted.")
|
|
222
|
+
else
|
|
223
|
+
@message_stream.add_message(role: :system, content: "Macro '#{name}' not found.")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
128
227
|
def snippet_delete(name)
|
|
129
228
|
unless name
|
|
130
229
|
@message_stream.add_message(role: :system, content: 'Usage: /snippet delete <name>')
|
|
@@ -162,6 +162,70 @@ module Legion
|
|
|
162
162
|
:handled
|
|
163
163
|
end
|
|
164
164
|
|
|
165
|
+
# rubocop:disable Metrics/AbcSize
|
|
166
|
+
def handle_react(input)
|
|
167
|
+
parts = input.split(nil, 3)
|
|
168
|
+
if parts.size == 2
|
|
169
|
+
emoji = parts[1]
|
|
170
|
+
msg = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
|
|
171
|
+
elsif parts.size >= 3 && parts[1].match?(/\A\d+\z/)
|
|
172
|
+
idx = parts[1].to_i
|
|
173
|
+
emoji = parts[2]
|
|
174
|
+
msg = @message_stream.messages[idx]
|
|
175
|
+
else
|
|
176
|
+
@message_stream.add_message(role: :system, content: 'Usage: /react <emoji> or /react <N> <emoji>')
|
|
177
|
+
return :handled
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
unless msg
|
|
181
|
+
@message_stream.add_message(role: :system, content: 'No message to react to.')
|
|
182
|
+
return :handled
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
msg[:reactions] ||= []
|
|
186
|
+
msg[:reactions] << emoji
|
|
187
|
+
@message_stream.add_message(role: :system, content: "Reaction #{emoji} added.")
|
|
188
|
+
:handled
|
|
189
|
+
end
|
|
190
|
+
# rubocop:enable Metrics/AbcSize
|
|
191
|
+
|
|
192
|
+
# rubocop:disable Metrics/AbcSize
|
|
193
|
+
def handle_tag(input)
|
|
194
|
+
parts = input.split(nil, 3)
|
|
195
|
+
if parts.size == 2
|
|
196
|
+
label = parts[1]
|
|
197
|
+
msg = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
|
|
198
|
+
elsif parts.size >= 3 && parts[1].match?(/\A\d+\z/)
|
|
199
|
+
idx = parts[1].to_i
|
|
200
|
+
label = parts[2]
|
|
201
|
+
msg = @message_stream.messages[idx]
|
|
202
|
+
else
|
|
203
|
+
@message_stream.add_message(role: :system, content: 'Usage: /tag <label> or /tag <N> <label>')
|
|
204
|
+
return :handled
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
unless msg
|
|
208
|
+
@message_stream.add_message(role: :system, content: 'No message to tag.')
|
|
209
|
+
return :handled
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
msg[:tags] ||= []
|
|
213
|
+
msg[:tags] |= [label]
|
|
214
|
+
@message_stream.add_message(role: :system, content: "Tag '#{label}' added.")
|
|
215
|
+
:handled
|
|
216
|
+
end
|
|
217
|
+
# rubocop:enable Metrics/AbcSize
|
|
218
|
+
|
|
219
|
+
def handle_tags(input)
|
|
220
|
+
label = input.split(nil, 2)[1]
|
|
221
|
+
if label
|
|
222
|
+
filter_messages_by_tag(label)
|
|
223
|
+
else
|
|
224
|
+
show_all_tags
|
|
225
|
+
end
|
|
226
|
+
:handled
|
|
227
|
+
end
|
|
228
|
+
|
|
165
229
|
def search_messages(query)
|
|
166
230
|
pattern = query.downcase
|
|
167
231
|
@message_stream.messages.select do |msg|
|
|
@@ -184,6 +248,34 @@ module Legion
|
|
|
184
248
|
nil
|
|
185
249
|
end
|
|
186
250
|
end
|
|
251
|
+
|
|
252
|
+
# rubocop:disable Metrics/AbcSize
|
|
253
|
+
def show_all_tags
|
|
254
|
+
tagged = @message_stream.messages.select { |m| m[:tags]&.any? }
|
|
255
|
+
if tagged.empty?
|
|
256
|
+
@message_stream.add_message(role: :system, content: 'No tagged messages.')
|
|
257
|
+
return
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
counts = Hash.new(0)
|
|
261
|
+
tagged.each { |m| m[:tags].each { |t| counts[t] += 1 } }
|
|
262
|
+
lines = counts.sort.map { |tag, count| " ##{tag} (#{count})" }
|
|
263
|
+
@message_stream.add_message(role: :system, content: "Tags:\n#{lines.join("\n")}")
|
|
264
|
+
end
|
|
265
|
+
# rubocop:enable Metrics/AbcSize
|
|
266
|
+
|
|
267
|
+
def filter_messages_by_tag(label)
|
|
268
|
+
results = @message_stream.messages.select { |m| m[:tags]&.include?(label) }
|
|
269
|
+
if results.empty?
|
|
270
|
+
@message_stream.add_message(role: :system, content: "No messages tagged '##{label}'.")
|
|
271
|
+
else
|
|
272
|
+
lines = results.map { |r| " [#{r[:role]}] #{truncate_text(r[:content].to_s, 80)}" }
|
|
273
|
+
@message_stream.add_message(
|
|
274
|
+
role: :system,
|
|
275
|
+
content: "Messages tagged '##{label}' (#{results.size}):\n#{lines.join("\n")}"
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
187
279
|
end
|
|
188
280
|
# rubocop:enable Metrics/ModuleLength
|
|
189
281
|
end
|
|
@@ -4,6 +4,7 @@ module Legion
|
|
|
4
4
|
module TTY
|
|
5
5
|
module Screens
|
|
6
6
|
class Chat < Base
|
|
7
|
+
# rubocop:disable Metrics/ModuleLength
|
|
7
8
|
module SessionCommands
|
|
8
9
|
private
|
|
9
10
|
|
|
@@ -76,6 +77,75 @@ module Legion
|
|
|
76
77
|
:handled
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
def handle_import(input)
|
|
81
|
+
path = input.split(nil, 2)[1]
|
|
82
|
+
unless path
|
|
83
|
+
@message_stream.add_message(role: :system, content: 'Usage: /import <path>')
|
|
84
|
+
return :handled
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
load_import_file(File.expand_path(path))
|
|
88
|
+
rescue ::JSON::ParserError => e
|
|
89
|
+
@message_stream.add_message(role: :system, content: "Invalid JSON: #{e.message}")
|
|
90
|
+
:handled
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def load_import_file(path)
|
|
94
|
+
unless File.exist?(path)
|
|
95
|
+
@message_stream.add_message(role: :system, content: "File not found: #{path}")
|
|
96
|
+
return :handled
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
data = ::JSON.parse(File.read(path), symbolize_names: true)
|
|
100
|
+
unless data.is_a?(Hash) && data[:messages].is_a?(Array)
|
|
101
|
+
@message_stream.add_message(role: :system, content: 'Invalid session file: missing messages array.')
|
|
102
|
+
return :handled
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
apply_imported_messages(data[:messages], path)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def apply_imported_messages(messages, path)
|
|
109
|
+
imported = messages.map { |m| { role: m[:role].to_sym, content: m[:content].to_s } }
|
|
110
|
+
@message_stream.messages.replace(imported)
|
|
111
|
+
@status_bar.notify(message: "Imported #{imported.size} messages", level: :success, ttl: 3)
|
|
112
|
+
@message_stream.add_message(role: :system, content: "Imported #{imported.size} messages from #{path}.")
|
|
113
|
+
:handled
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def handle_autosave(input)
|
|
117
|
+
arg = input.split(nil, 2)[1]
|
|
118
|
+
if arg.nil?
|
|
119
|
+
@autosave_enabled = !@autosave_enabled
|
|
120
|
+
status = @autosave_enabled ? "ON (every #{@autosave_interval}s)" : 'OFF'
|
|
121
|
+
@status_bar.notify(message: "Autosave: #{status}", level: :info, ttl: 3)
|
|
122
|
+
@message_stream.add_message(role: :system, content: "Autosave #{status}.")
|
|
123
|
+
elsif arg == 'off'
|
|
124
|
+
@autosave_enabled = false
|
|
125
|
+
@status_bar.notify(message: 'Autosave: OFF', level: :info, ttl: 3)
|
|
126
|
+
@message_stream.add_message(role: :system, content: 'Autosave OFF.')
|
|
127
|
+
elsif arg.match?(/\A\d+\z/)
|
|
128
|
+
@autosave_interval = arg.to_i
|
|
129
|
+
@autosave_enabled = true
|
|
130
|
+
@status_bar.notify(message: "Autosave: ON (every #{@autosave_interval}s)", level: :info, ttl: 3)
|
|
131
|
+
@message_stream.add_message(role: :system, content: "Autosave ON (every #{@autosave_interval}s).")
|
|
132
|
+
else
|
|
133
|
+
@message_stream.add_message(role: :system, content: 'Usage: /autosave [off|<seconds>]')
|
|
134
|
+
end
|
|
135
|
+
:handled
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def check_autosave
|
|
139
|
+
return unless @autosave_enabled
|
|
140
|
+
return unless Time.now - @last_autosave >= @autosave_interval
|
|
141
|
+
|
|
142
|
+
auto_save_session
|
|
143
|
+
@last_autosave = Time.now
|
|
144
|
+
@status_bar.notify(message: 'Autosaved', level: :info, ttl: 2)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
79
149
|
def auto_save_session
|
|
80
150
|
return if @message_stream.messages.empty?
|
|
81
151
|
|
|
@@ -87,6 +157,7 @@ module Legion
|
|
|
87
157
|
nil
|
|
88
158
|
end
|
|
89
159
|
end
|
|
160
|
+
# rubocop:enable Metrics/ModuleLength
|
|
90
161
|
end
|
|
91
162
|
end
|
|
92
163
|
end
|
|
@@ -204,15 +204,62 @@ module Legion
|
|
|
204
204
|
end
|
|
205
205
|
end
|
|
206
206
|
|
|
207
|
+
def handle_wc
|
|
208
|
+
msgs = @message_stream.messages
|
|
209
|
+
by_role = word_counts_by_role(msgs)
|
|
210
|
+
total = by_role.values.sum
|
|
211
|
+
avg = (total.to_f / [msgs.size, 1].max).round
|
|
212
|
+
@message_stream.add_message(role: :system, content: build_wc_lines(by_role, total, avg).join("\n"))
|
|
213
|
+
:handled
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def word_counts_by_role(msgs)
|
|
217
|
+
%i[user assistant system].to_h do |role|
|
|
218
|
+
words = msgs.select { |m| m[:role] == role }.sum { |m| m[:content].to_s.split.size }
|
|
219
|
+
[role, words]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def build_wc_lines(by_role, total, avg)
|
|
224
|
+
[
|
|
225
|
+
'Word count:',
|
|
226
|
+
" Total: #{format_stat_number(total)}",
|
|
227
|
+
" User: #{format_stat_number(by_role[:user])}",
|
|
228
|
+
" Assistant: #{format_stat_number(by_role[:assistant])}",
|
|
229
|
+
" System: #{format_stat_number(by_role[:system])}",
|
|
230
|
+
" Avg words/message: #{avg}"
|
|
231
|
+
]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def handle_mute
|
|
235
|
+
@muted_system = !@muted_system
|
|
236
|
+
@message_stream.mute_system = @muted_system
|
|
237
|
+
if @muted_system
|
|
238
|
+
@status_bar.notify(message: 'System messages hidden', level: :info, ttl: 3)
|
|
239
|
+
else
|
|
240
|
+
@status_bar.notify(message: 'System messages visible', level: :info, ttl: 3)
|
|
241
|
+
end
|
|
242
|
+
:handled
|
|
243
|
+
end
|
|
244
|
+
|
|
207
245
|
def build_stats_lines
|
|
208
246
|
msgs = @message_stream.messages
|
|
209
247
|
counts = count_by_role(msgs)
|
|
210
248
|
total_chars = msgs.sum { |m| m[:content].to_s.length }
|
|
211
249
|
lines = stats_header_lines(msgs, counts, total_chars)
|
|
212
250
|
lines << " Tool calls: #{counts[:tool]}" if counts[:tool].positive?
|
|
251
|
+
append_response_time_stat(lines, msgs)
|
|
213
252
|
lines
|
|
214
253
|
end
|
|
215
254
|
|
|
255
|
+
def append_response_time_stat(lines, msgs)
|
|
256
|
+
timed = msgs.select { |m| m[:response_time] }
|
|
257
|
+
return unless timed.any?
|
|
258
|
+
|
|
259
|
+
avg_rt = timed.sum { |m| m[:response_time] }.to_f / timed.size
|
|
260
|
+
lines << " Avg response time: #{avg_rt.round(2)}s (#{timed.size} responses)"
|
|
261
|
+
end
|
|
262
|
+
|
|
216
263
|
def count_by_role(msgs)
|
|
217
264
|
%i[user assistant system tool].to_h { |role| [role, msgs.count { |m| m[:role] == role }] }
|
|
218
265
|
end
|
|
@@ -28,7 +28,8 @@ module Legion
|
|
|
28
28
|
SLASH_COMMANDS = %w[/help /quit /clear /compact /copy /diff /model /session /cost /export /tools /dashboard
|
|
29
29
|
/hotkeys /save /load /sessions /system /delete /plan /palette /extensions /config
|
|
30
30
|
/theme /search /grep /stats /personality /undo /history /pin /pins /rename
|
|
31
|
-
/context /alias /snippet /debug /uptime /time /bookmark /welcome /tips
|
|
31
|
+
/context /alias /snippet /debug /uptime /time /bookmark /welcome /tips
|
|
32
|
+
/wc /import /mute /autosave /react /macro /tag /tags].freeze
|
|
32
33
|
|
|
33
34
|
PERSONALITIES = {
|
|
34
35
|
'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
|
|
@@ -40,7 +41,7 @@ module Legion
|
|
|
40
41
|
|
|
41
42
|
attr_reader :message_stream, :status_bar
|
|
42
43
|
|
|
43
|
-
# rubocop:disable Metrics/AbcSize
|
|
44
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
44
45
|
def initialize(app, output: $stdout, input_bar: nil)
|
|
45
46
|
super(app)
|
|
46
47
|
@output = output
|
|
@@ -56,11 +57,18 @@ module Legion
|
|
|
56
57
|
@pinned_messages = []
|
|
57
58
|
@aliases = {}
|
|
58
59
|
@snippets = {}
|
|
60
|
+
@macros = {}
|
|
59
61
|
@debug_mode = false
|
|
60
62
|
@session_start = Time.now
|
|
63
|
+
@muted_system = false
|
|
64
|
+
@autosave_enabled = false
|
|
65
|
+
@autosave_interval = 60
|
|
66
|
+
@last_autosave = Time.now
|
|
67
|
+
@recording_macro = nil
|
|
68
|
+
@macro_buffer = []
|
|
61
69
|
end
|
|
62
70
|
|
|
63
|
-
# rubocop:enable Metrics/AbcSize
|
|
71
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
64
72
|
|
|
65
73
|
def activate
|
|
66
74
|
@running = true
|
|
@@ -114,7 +122,9 @@ module Legion
|
|
|
114
122
|
return handle_slash_command("#{expanded} #{input.split(nil, 2)[1]}".strip)
|
|
115
123
|
end
|
|
116
124
|
|
|
117
|
-
dispatch_slash(cmd, input)
|
|
125
|
+
result = dispatch_slash(cmd, input)
|
|
126
|
+
record_macro_step(input, cmd, result)
|
|
127
|
+
result
|
|
118
128
|
end
|
|
119
129
|
|
|
120
130
|
def handle_user_message(input)
|
|
@@ -126,6 +136,7 @@ module Legion
|
|
|
126
136
|
send_to_llm(input)
|
|
127
137
|
end
|
|
128
138
|
@status_bar.update(message_count: @message_stream.messages.size)
|
|
139
|
+
check_autosave
|
|
129
140
|
render_screen
|
|
130
141
|
end
|
|
131
142
|
|
|
@@ -173,6 +184,14 @@ module Legion
|
|
|
173
184
|
|
|
174
185
|
private
|
|
175
186
|
|
|
187
|
+
def record_macro_step(input, cmd, result)
|
|
188
|
+
return unless @recording_macro
|
|
189
|
+
return if cmd == '/macro'
|
|
190
|
+
return unless result == :handled
|
|
191
|
+
|
|
192
|
+
@macro_buffer << input
|
|
193
|
+
end
|
|
194
|
+
|
|
176
195
|
def setup_system_prompt
|
|
177
196
|
cfg = safe_config
|
|
178
197
|
return unless @llm_chat && cfg.is_a?(Hash) && !cfg.empty?
|
|
@@ -202,15 +221,23 @@ module Legion
|
|
|
202
221
|
|
|
203
222
|
@status_bar.update(thinking: true)
|
|
204
223
|
render_screen
|
|
224
|
+
start_time = Time.now
|
|
205
225
|
response = @llm_chat.ask(message) do |chunk|
|
|
206
226
|
@status_bar.update(thinking: false)
|
|
207
227
|
@message_stream.append_streaming(chunk.content) if chunk.content
|
|
208
228
|
render_screen
|
|
209
229
|
end
|
|
230
|
+
record_response_time(Time.now - start_time)
|
|
210
231
|
@status_bar.update(thinking: false)
|
|
211
232
|
track_response_tokens(response)
|
|
212
233
|
end
|
|
213
234
|
|
|
235
|
+
def record_response_time(elapsed)
|
|
236
|
+
@last_response_time = elapsed
|
|
237
|
+
@message_stream.messages.last[:response_time] = elapsed if @message_stream.messages.last
|
|
238
|
+
@status_bar.notify(message: "Response: #{elapsed.round(1)}s", level: :info, ttl: 4)
|
|
239
|
+
end
|
|
240
|
+
|
|
214
241
|
def daemon_available?
|
|
215
242
|
!!(defined?(Legion::LLM::DaemonClient) && Legion::LLM::DaemonClient.available?)
|
|
216
243
|
end
|
|
@@ -340,6 +367,14 @@ module Legion
|
|
|
340
367
|
when '/bookmark' then handle_bookmark
|
|
341
368
|
when '/welcome' then handle_welcome
|
|
342
369
|
when '/tips' then handle_tips
|
|
370
|
+
when '/wc' then handle_wc
|
|
371
|
+
when '/import' then handle_import(input)
|
|
372
|
+
when '/mute' then handle_mute
|
|
373
|
+
when '/autosave' then handle_autosave(input)
|
|
374
|
+
when '/react' then handle_react(input)
|
|
375
|
+
when '/macro' then handle_macro(input)
|
|
376
|
+
when '/tag' then handle_tag(input)
|
|
377
|
+
when '/tags' then handle_tags(input)
|
|
343
378
|
else :handled
|
|
344
379
|
end
|
|
345
380
|
end
|
|
@@ -418,7 +453,9 @@ module Legion
|
|
|
418
453
|
"personality:#{@personality || 'default'} " \
|
|
419
454
|
"aliases:#{@aliases.size} " \
|
|
420
455
|
"snippets:#{@snippets.size} " \
|
|
421
|
-
"
|
|
456
|
+
"macros:#{@macros.size} " \
|
|
457
|
+
"pinned:#{@pinned_messages.size} " \
|
|
458
|
+
"autosave:#{@autosave_enabled}"
|
|
422
459
|
end
|
|
423
460
|
|
|
424
461
|
def build_default_input_bar
|
data/lib/legion/tty/version.rb
CHANGED