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 +4 -4
- data/CHANGELOG.md +15 -0
- 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 +217 -0
- data/lib/legion/tty/screens/chat.rb +45 -799
- data/lib/legion/tty/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9506a28fb68d7623dd1e70e2b69b80e80b2a96486c068dbc2d13a7e2bd9a80ab
|
|
4
|
+
data.tar.gz: 7ce9e32334ba94891569b073e04246240d8b1637edb930f0c8c86ed80841a155
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
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
|